Skip to content

Migrate thread selection to URL-driven state with browser history support#883

Open
JSv4 wants to merge 13 commits intomainfrom
claude/fix-mobile-navigation-fSBZh
Open

Migrate thread selection to URL-driven state with browser history support#883
JSv4 wants to merge 13 commits intomainfrom
claude/fix-mobile-navigation-fSBZh

Conversation

@JSv4
Copy link
Collaborator

@JSv4 JSv4 commented Feb 11, 2026

Summary

This PR migrates thread selection in the Discussions tab from local atom state to URL-driven state via the ?thread= query parameter. This enables proper browser back/forward navigation between thread list and thread detail views, and aligns with the CentralRouteManager architecture for managing application state through URLs.

Key Changes

  • Thread Selection State Migration: Replaced inlineSelectedThreadIdAtom with URL-driven state synced through selectedThreadIdVar reactive var in the Apollo cache. The ?thread= query parameter is now the source of truth.

  • Navigation Utilities:

    • Added updateThreadParam() function to manage thread URL parameter updates with proper history handling
    • Updated updateTabParam() and updateDetailViewParam() to use navigate() instead of replace: true, enabling proper browser back/forward navigation
    • Both functions now clear related params when switching contexts to prevent stale state
  • CorpusDiscussionsView Refactoring:

    • Removed local atom state management for thread selection
    • Updated handleThreadClick() to call updateThreadParam() instead of setting atom state
    • Updated handleBackToList() to use navigate(-1) for proper history navigation
    • Removed cleanup effect that was resetting state on unmount
    • Now reads thread selection from selectedThreadIdVar reactive var
  • State Derivation in Parent Component:

    • Corpuses.tsx now derives discussionInThreadView from the URL-driven selectedThreadId reactive var instead of managing it as local state
    • Removed onViewModeChange callback from CorpusDiscussionsView since state is now URL-driven
  • Mobile UI Enhancements:

    • Added MobileMenuButton styled component for mobile navigation menu access
    • Updated CorpusLandingView and CorpusDetailsView to include mobile menu button in headers
    • Added onOpenMobileMenu callback prop to support mobile navigation
  • Cleanup: Removed inlineSelectedThreadIdAtom from threadAtoms.ts with explanatory comment about URL-driven approach

Implementation Details

The migration follows the CentralRouteManager Phase 2 pattern where:

  1. User interactions update the URL via updateThreadParam()
  2. CentralRouteManager reads the ?thread= param and syncs it to selectedThreadIdVar
  3. Components read from the reactive var and re-render when it changes
  4. Browser back/forward naturally works because we use navigate() instead of replace: true

This approach eliminates the need for callback-based state synchronization and provides a single source of truth (the URL) for thread selection state.

https://claude.ai/code/session_015gUfjhz6wHKpvJiEiXgBA7

…ead nav

Three issues addressed:

1. Mobile corpus home view had no way to access sidebar tabs. Added a
   MobileMenuButton (kebab icon) to both CorpusLandingView (in breadcrumbs)
   and CorpusDetailsView (in header) that opens the sidebar bottom sheet,
   consistent with how other tabs expose the MobileKebabButton.

2. Discussion thread navigation was not URL-driven, making browser
   back/forward unusable. Replaced the local inlineSelectedThreadIdAtom
   (Jotai) with the URL-synced selectedThreadId reactive var (?thread=
   param), already handled by CentralRouteManager. Added updateThreadParam
   utility that pushes history entries for proper back/forward.

3. Tab switching used replace instead of push, preventing browser
   back/forward between tabs. Changed updateTabParam and
   updateDetailViewParam to push new history entries. updateTabParam also
   now clears thread/message params when switching tabs.

https://claude.ai/code/session_015gUfjhz6wHKpvJiEiXgBA7
@claude
Copy link

claude bot commented Feb 11, 2026

Pull Request Review: URL-Driven Thread Selection Migration

This PR successfully migrates thread selection from local Jotai atom state to URL-driven state via the ?thread= query parameter. The implementation aligns well with the CentralRouteManager Phase 2 pattern and follows the project's routing conventions. Below is my detailed review:


✅ Strengths

Architecture Alignment

  • Correct pattern adherence: The migration properly follows the CentralRouteManager Phase 2 pattern where components update the URL via utility functions, and CentralRouteManager syncs the URL params to reactive vars
  • Unidirectional data flow: Component → URL → CentralRouteManager → Reactive Var → Component (documented in docs/frontend/routing_system.md:54-55)
  • Proper cleanup: Removed the deprecated inlineSelectedThreadIdAtom with a clear explanatory comment about the new URL-driven approach

Browser Navigation Support

  • History management: Changed from replace: true to push-based navigation in updateTabParam(), updateDetailViewParam(), and new updateThreadParam() functions, enabling proper browser back/forward navigation
  • Proper back navigation: handleBackToList() correctly uses navigate(-1) to pop the history entry created by handleThreadClick()
  • State cleanup: updateTabParam() now clears thread/message params when switching tabs to prevent stale state

Mobile UX Improvements

  • Consistent menu access: Added MobileMenuButton (kebab icon) to both CorpusLandingView and CorpusDetailsView, consistent with other tab headers
  • Proper visual styling: Uses existing design system constants and media queries for responsive behavior

Code Quality

  • Clear documentation: Comprehensive JSDoc comments explain the navigation flow and history behavior
  • Consistent naming: updateThreadParam() follows the same naming pattern as updateTabParam(), updateMessageParam(), etc.
  • Proper hooks: Dependencies correctly specified in useCallback hooks

⚠️ Issues & Recommendations

1. Missing Test Coverage for New Functionality (Medium Priority)

Issue: The new updateThreadParam() function has no unit tests, and the existing component test doesn't verify the URL-driven behavior.

Evidence:

  • frontend/src/utils/__tests__/navigationUtils.test.ts has no tests for updateThreadParam
  • frontend/tests/discussions/CorpusDiscussionsView.test.tsx uses MemoryRouter without query params, doesn't test thread selection or navigation

Recommendation: Add tests to verify:

// In navigationUtils.test.ts
describe('updateThreadParam()', () => {
  it('should set thread query param when threadId provided', () => { /* ... */ });
  it('should clear thread and message params when threadId is null', () => { /* ... */ });
  it('should push history entry, not replace', () => { /* ... */ });
});

// In CorpusDiscussionsView.test.tsx
test('should update URL when thread is clicked', async ({ mount }) => {
  // Verify updateThreadParam called with correct threadId
});
test('should navigate back when handleBackToList called', async ({ mount }) => {
  // Verify navigate(-1) called
});

Impact: Without tests, regressions in the new URL-driven thread navigation could go undetected. Per CLAUDE.md baseline commit rules, all affected tests should pass before committing.


2. Inconsistent Prop Removal (Low Priority)

Issue: onViewModeChange prop is removed from CorpusDiscussionsView invocation in Corpuses.tsx:2405, but the prop definition remains in the component interface and is still used internally (CorpusDiscussionsView.tsx:398).

Current state:

// Corpuses.tsx:2405 - prop not passed
<CorpusDiscussionsView corpusId={opened_corpus.id} hideHeader />

// CorpusDiscussionsView.tsx:34 - prop still defined
export interface CorpusDiscussionsViewProps {
  onViewModeChange?: (inThreadView: boolean) => void; // Still here
}

// CorpusDiscussionsView.tsx:398 - prop still called
useEffect(() => {
  onViewModeChange?.(inThreadView); // Optional chaining prevents errors
}, [inThreadView, onViewModeChange]);

Recommendation: Since discussionInThreadView is now derived directly from selectedThreadId in the parent component (Corpuses.tsx:1583-1584), the callback is no longer needed. Either:

  1. Clean removal: Remove the prop from the interface and the useEffect in CorpusDiscussionsView.tsx
  2. Keep for extensibility: Document why it's kept if future use cases exist

Impact: Minor - the optional chaining prevents runtime errors, but it leaves dead code that could confuse future maintainers.


3. Potential Mobile Menu Prop Drilling (Low Priority)

Issue: The onOpenMobileMenu prop is threaded through CorpusHomeCorpusLandingView / CorpusDetailsView but its source isn't visible in this PR.

Current propagation:

Corpuses.tsx (parent, not in diff) 
  → CorpusHome.tsx:53 (receives prop)
    → CorpusLandingView.tsx:88 / CorpusDetailsView.tsx:96 (uses prop)

Recommendation: Verify that:

  • The prop is actually provided by the parent component (check Corpuses.tsx full implementation)
  • The mobile menu state management aligns with project patterns (reactive vars vs local state)
  • Optional chaining is appropriate if the prop may be undefined in some contexts

Impact: If the prop isn't provided upstream, the buttons will render but do nothing on click (no-op due to onClick={onOpenMobileMenu} without optional chaining).


4. CHANGELOG.md Not Updated (Medium Priority)

Issue: Per CLAUDE.md guidelines (CLAUDE.md:322-348), significant changes should be documented in CHANGELOG.md.

This PR includes:

  • New feature: URL-driven thread navigation with browser history support
  • UX improvement: Mobile menu access in corpus home views
  • Breaking change: Thread selection state migration from atoms to URL params

Recommendation: Add an entry to CHANGELOG.md under ## [Unreleased]:

### Changed
- **Thread selection now URL-driven**: Discussions tab thread selection uses `?thread=` query parameter instead of local atom state, enabling proper browser back/forward navigation between thread list and detail views
  - Files: `frontend/src/components/discussions/CorpusDiscussionsView.tsx`, `frontend/src/utils/navigationUtils.ts`, `frontend/src/views/Corpuses.tsx`

### Added  
- **Mobile navigation menu access**: Added kebab menu buttons to corpus landing and details views for accessing sidebar navigation on mobile devices
  - Files: `frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx`, `frontend/src/components/corpuses/CorpusHome/CorpusDetailsView.tsx`

🔍 Code Quality Assessment

Positive Patterns

✅ No direct manipulation of reactive vars (respects CentralRouteManager ownership)
✅ Proper use of useCallback with correct dependencies
✅ Consistent with existing navigation utility patterns
✅ Good inline documentation
✅ Mobile-first responsive design

Potential Concerns

⚠️ Tests don't verify new URL navigation behavior
⚠️ Dead prop interface definition (onViewModeChange)
⚠️ Missing CHANGELOG entry


🛡️ Security & Performance

Security: No concerns. Navigation utilities sanitize query params through URLSearchParams, preventing XSS.

Performance: No concerns. The change actually reduces state synchronization overhead by eliminating callback-based state propagation.


📋 Pre-Merge Checklist

Before merging, please ensure:

  • Tests added for updateThreadParam() in navigationUtils.test.ts
  • Component tests updated to verify URL navigation behavior
  • TypeScript compiles (cd frontend && yarn build)
  • Pre-commit hooks pass (pre-commit run --all-files)
  • Verify onOpenMobileMenu prop is wired in Corpuses.tsx
  • Remove or document onViewModeChange if truly dead code
  • Update CHANGELOG.md per project guidelines
  • Manual testing: Verify browser back/forward works in discussions tab

Summary

This is a well-architected PR that correctly implements the URL-driven state pattern. The navigation flow is sound, and the mobile UX improvements are a welcome addition. The main gaps are in test coverage and documentation. Once those are addressed, this will be a solid contribution to the routing system migration.

Overall Assessment: ✅ Approve with minor changes requested

Great work following the architectural patterns! 🎉

- Remove dead `onViewModeChange` prop and `useEffect` from
  CorpusDiscussionsView — parent now derives thread view state directly
  from the URL-synced selectedThreadId reactive var
- Remove unused `useEffect` import
- Update existing `updateTabParam()` tests to assert push semantics
  (no `{ replace: true }`) and add 3 new tests for thread/message
  param clearing on tab switch
- Add 5 new `updateThreadParam()` tests covering set, clear, push
  behavior, param preservation, and update
- Add CHANGELOG.md entry documenting mobile nav and URL-driven thread
  selection changes

https://claude.ai/code/session_015gUfjhz6wHKpvJiEiXgBA7
@claude
Copy link

claude bot commented Feb 11, 2026

Code Review - PR #883

I've completed a thorough review of this PR. Overall, this is a well-architected migration that successfully aligns thread selection with the CentralRouteManager pattern. The implementation is clean, well-tested, and follows the project's established conventions.


✅ Strengths

1. Excellent Architecture Alignment

  • Properly follows the CentralRouteManager Phase 2 pattern documented in docs/frontend/routing_system.md
  • URL is now the single source of truth for thread selection state
  • Eliminates callback-based state synchronization in favor of URL-driven reactive vars
  • Browser back/forward navigation works correctly with navigate() instead of replace: true

2. Comprehensive Test Coverage

The test updates in navigationUtils.test.ts are thorough:

  • updateTabParam() tests verify history pushing (not replacing) for proper back/forward navigation
  • updateThreadParam() tests confirm thread selection and clearing behavior
  • Tests verify tab-specific params (thread, message) are properly cleared when switching tabs
  • All edge cases are covered (null values, preserving unrelated params, etc.)

3. Clean Code Quality

  • Removed dead code (inlineSelectedThreadIdAtom, onViewModeChange callback)
  • Added explanatory comments about URL-driven approach
  • Navigation utilities are well-documented with JSDoc comments
  • Proper separation of concerns (URL management in utilities, UI in components)

4. Mobile UX Enhancement

The mobile menu button addition is a thoughtful improvement:

  • Gives mobile users access to navigation from corpus home views
  • Consistent placement across CorpusLandingView and CorpusDetailsView
  • Uses existing styled component pattern

5. Excellent Changelog Documentation

The CHANGELOG.md updates are detailed and follow the Keep a Changelog format properly, with clear file references and line numbers.


🔍 Code Quality Observations

navigationUtils.ts (Lines 806-857)

The new utility functions are well-designed:

Good:

  • updateTabParam() correctly pushes history for tab navigation (Line 831)
  • Properly clears tab-specific params when switching tabs (Lines 827-829)
  • updateThreadParam() enables browser back/forward with history push (Line 856)
  • Clears message param when clearing thread (Line 853)

Good Pattern: Both functions preserve unrelated params while updating/clearing specific ones

CorpusDiscussionsView.tsx (Lines 385-458)

State management migration is clean:

Good:

  • Reads from URL-driven reactive var instead of local atom (Line 387)
  • handleThreadClick properly updates URL param (Lines 447-452)
  • handleBackToList uses navigate(-1) for proper history navigation (Lines 456-458)
  • Removed cleanup effect that was resetting state on unmount (good - URL state persists across navigation)

Corpuses.tsx (Lines 1582-1584)

Parent component correctly derives state:

Good:

  • Derives discussionInThreadView from URL-driven reactive var
  • No more local state management for this concern
  • Cleaner component logic

⚠️ Potential Issues & Recommendations

1. Minor: Typo in Corpuses.tsx (Line 1586)

/ Debug: Log state changes  // Missing second slash

Fix: Change to // for proper comment syntax

2. Performance: Consider Debouncing Thread Selection (Optional)

If users rapidly click through threads, each click pushes a new history entry. This could make the back button feel "sluggish" if many entries accumulate.

Recommendation: Consider debouncing handleThreadClick or using replace: true for rapid successive clicks within a short time window (e.g., 500ms). This is a minor optimization and not critical.

3. Test Coverage: Component Tests (Not Blocking)

While the unit tests for navigationUtils.ts are excellent, I don't see corresponding component tests for:

  • CorpusDiscussionsView thread selection behavior
  • Browser back/forward navigation in discussions tab

Recommendation: Add Playwright component tests following the pattern in .cursor/rules/test-document-knowledge-base.mdc:

// Pseudo-code example
test('should navigate to thread detail when thread clicked', async ({ mount }) => {
  const component = await mount(<DiscussionsTestWrapper />);
  await component.getByTestId('thread-item-123').click();
  expect(window.location.search).toContain('thread=123');
});

test('should return to list when back button clicked', async ({ mount, page }) => {
  // ... navigate to thread, then click back
  await page.goBack();
  expect(window.location.search).not.toContain('thread=');
});

4. Documentation: Update Architecture Docs (Minor)

The PR mentions following the CentralRouteManager pattern, but doesn't update docs/frontend/routing_system.md to document this specific thread selection migration.

Recommendation: Add a brief section to the routing docs showing thread selection as an example of Phase 2 URL-driven state.


🔒 Security Review

No security concerns identified

  • URL params are properly sanitized through URLSearchParams
  • No user input is directly injected into URLs
  • Reactive vars prevent XSS through proper React rendering

🎯 Performance Considerations

Good:

  • Removed unnecessary state synchronization callbacks
  • URL-driven state eliminates potential desync issues
  • Reactive vars provide efficient re-rendering

⚠️ Minor: As mentioned above, rapid thread clicks create many history entries. Not a significant performance issue, just a UX consideration.


📋 Best Practices Adherence

Follows CLAUDE.md conventions:

  • ✅ No magic numbers (uses proper constants)
  • ✅ DRY principles (utility functions are reusable)
  • ✅ Single responsibility (each function has one purpose)
  • ✅ Proper test coverage for utilities
  • ✅ Clean changelog documentation
  • ✅ No dead code left behind

Follows project's routing architecture:

  • ✅ URL as single source of truth
  • ✅ Components read from reactive vars
  • ✅ Navigation utilities handle URL updates
  • ✅ CentralRouteManager pattern correctly implemented

🚦 Recommendation

APPROVE with minor suggestions

This PR is ready to merge. The suggested improvements are optional enhancements that don't block merging:

Before Merge (Critical):

  • Fix typo in Corpuses.tsx Line 1586 (///)

Future Enhancements (Non-blocking):

  • Consider debouncing rapid thread clicks (performance optimization)
  • Add component tests for thread navigation (test coverage)
  • Update routing architecture docs with thread example (documentation)

📊 Summary

Code Quality: ⭐⭐⭐⭐⭐ (5/5)
Test Coverage: ⭐⭐⭐⭐ (4/5) - Unit tests excellent, component tests would make it perfect
Documentation: ⭐⭐⭐⭐ (4/5) - Excellent changelog, could add architecture doc update
Architecture: ⭐⭐⭐⭐⭐ (5/5) - Perfectly aligns with CentralRouteManager pattern

Overall: Excellent work! This migration is well-thought-out and properly executed. 🎉

JSv4 added 3 commits February 13, 2026 01:10
- Remove console.log debug statements left in Corpuses.tsx
- Replace navigate(-1) with updateThreadParam(null) in handleBackToList
  so back-to-list always explicitly clears the ?thread= param via URL,
  letting CentralRouteManager Phase 2 handle state — consistent with
  the routing mantra and safe for deep-linked/bookmarked URLs
Annotations in corpus-scoped threads can have a null document field,
causing "can't access property title, edge.node.document is null" when
typing @ mentions. Filter out null-document annotations before mapping.
@claude
Copy link

claude bot commented Feb 19, 2026

PR Review: Migrate thread selection to URL-driven state (#883)

Overall this is a well-structured refactor that correctly aligns with the CentralRouteManager architecture. The core approach is sound — selectedThreadId is already being synced bidirectionally in CentralRouteManager (Phase 2: searchParams.get("thread") → reactive var at line 861; Phase 4: reactive var → URL at line 1041), so removing the Jotai atom is safe. The null-document filter fix in useUnifiedMentionSearch is a clean, targeted bug fix.


Issues to Address

1. Inline styles in CorpusDetailsView.tsx (style violation)

The header wrapper in the details view uses inline styles instead of a styled component:

// CorpusDetailsView.tsx
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>

The rest of the file uses styled components from styles.ts exclusively. This should be extracted to styles.ts alongside the new MobileMenuButton:

// styles.ts
export const DetailsHeaderRow = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
`;

2. Tab navigation push semantics — browser history experience

Changing updateTabParam from replace: true to push means every tab click creates a new browser history entry. The documented intent is correct for thread-level navigation (back button = "go back to thread list"), but the same treatment for tab switching is likely to feel unintuitive: a user who clicks Documents → Discussions → Annotations would need to press back 3 times to leave the corpus. Tabs are typically replace-level navigation.

Consider splitting the semantics:

  • updateTabParam — keep as replace (tabs = location, not steps)
  • updateThreadParam — push (thread detail = distinct navigation step) ✓
  • updateDetailViewParam — push (detail vs. landing = distinct step) ✓

The tests correctly document the new behavior, but the product behavior deserves a deliberate decision here.

3. No component-level integration test for URL-driven behavior

The utility function tests in navigationUtils.test.ts are thorough, but the actual integration — "URL ?thread=123 causes CorpusDiscussionsView to render ThreadDetailWithContext" — is untested. A Playwright component test verifying the following would prevent regressions:

  • Rendering with ?thread=<id> shows thread detail view
  • Clicking back updates URL to remove ?thread= and shows the list
  • Browser back button restores thread detail

Minor / Nits

4. NavigateFunction type for updateThreadParam

The navigate parameter uses a hand-rolled simplified type:

navigate: (to: { search: string }, options?: { replace?: boolean }) => void

Importing and using React Router's NavigateFunction directly would catch edge cases (e.g., absolute path navigation):

import type { NavigateFunction } from "react-router-dom";

The other navigation utilities in the same file already accept (location, navigate) pairs — worth checking if there's a pattern to follow there.

5. clearThreadSelection in navigationUtils.ts may now be redundant

clearThreadSelection appears to do what updateThreadParam(location, navigate, null) now does. If it's used elsewhere, it should delegate to updateThreadParam internally. If it's unused after this PR, it should be removed per the "no dead code" rule in CLAUDE.md.

6. Comment in dependency array

currentSelectedThreadId, // Required for discussions tab header visibility (URL-driven)

Comments inside dependency arrays are a smell — if currentSelectedThreadId is needed, it's needed; the comment is noise. The comment belongs in the useCallback/useMemo body if anywhere.


What's Done Well

  • Removing inlineSelectedThreadIdAtom is the right call — it was duplicating state that CentralRouteManager already owns
  • Deleting the two useEffect hooks (cleanup on unmount, parent notification) simplifies the component meaningfully
  • Removing onViewModeChange from CorpusDiscussionsView's public API and having the parent read from the reactive var directly is cleaner
  • The MobileMenuButton component follows the project's styled-component conventions, is properly hidden via CSS (not conditional render), and is accessible
  • updateThreadParam is clean and well-tested
  • Debug console.log removal in Corpuses.tsx — good
  • CHANGELOG updated per project requirements

JSv4 added 2 commits February 19, 2026 06:56
- MessageComposer: collapses to single-line when unfocused, expands
  toolbar/editor/footer on focus via CSS transitions
- Single editor (Reddit-style): remove inline ReplyForm from MessageTree,
  bottom composer switches between top-level and reply mode with context bar
  showing @username + message preview (truncated, with cancel button)
- Thread header: merge Back/badge/title/status onto one row, description
  (left, truncated with tooltip) and author/time/count (right) on second row
- Reduce padding throughout for better vertical space utilization
… left-aligned meta

- Back button: icon-only, no border/text
- Title row: badge + title (no-shrink) + description (truncates with tooltip)
- Description: smaller font, no em dash, relies on color/size contrast
- Meta row: author/time/count left-aligned below title
@claude
Copy link

claude bot commented Feb 19, 2026

PR Review: Thread URL-Driven State & Mobile Navigation

Overall this is a well-structured migration that aligns with the CentralRouteManager architecture. The core idea is sound. A few items worth discussing before merge:


Issues / Questions

1. PR Description Discrepancy — handleBackToList vs navigate(-1)

The PR description states:

Updated handleBackToList() to use navigate(-1) for proper history navigation

But the implementation in CorpusDiscussionsView.tsx does:

const handleBackToList = useCallback(() => {
  updateThreadParam(location, navigate, null);
}, [location, navigate]);

updateThreadParam(null) calls navigate({ search: ... }) (push), not navigate(-1) (pop). This creates an extra history entry rather than popping the existing one. The consequence:

  1. User is at ?tab=discussions
  2. Clicks thread → ?tab=discussions&thread=123 pushed
  3. Clicks "Back" in thread detail → ?tab=discussions pushed (new entry)
  4. Browser back → returns to ?tab=discussions&thread=123 (unexpected re-entry to thread)
  5. Browser back again → returns to ?tab=discussions

Using navigate(-1) would give a cleaner single-pop behavior when the in-app Back button is clicked. If push semantics are intentional here (for forward-nav consistency), the PR description should be updated to reflect this choice.


2. updateTabParam — no guard against no-op pushes

With { replace: true } removed, clicking the already-active tab will push a redundant history entry because there's no check for "are we already on this tab?":

// Current code: always pushes regardless
searchParams.delete("thread");
searchParams.delete("message");
navigate({ search: searchParams.toString() });

A simple guard would prevent stack pollution:

if (
  searchParams.get("tab") === tabId &&
  !searchParams.has("thread") &&
  !searchParams.has("message")
) return; // already in this state

Similarly, rapid tab switching (Documents → Extracts → Documents → ...) now grows the history stack with every click, which may make the back button feel sluggish and confusing.


3. Removed "Quote" feature not documented in CHANGELOG

ReplyForm.tsx removes QuoteButton, QuotedMessage, showQuote state, and handleToggleQuote. This is a UX feature removal (users could previously expand a quoted version of the parent message before sending). The new CHANGELOG entry documents only what was added/changed, not this removal. Worth adding a ### Removed bullet to be transparent.


4. Title now white-space: nowrap in ThreadDetail

-  font-size: 28px;
+  font-size: 1.25rem;
+  flex-shrink: 0;
+  white-space: nowrap;

Long thread titles will be silently truncated (no ellipsis, just clipped by parent overflow). The Description sibling has text-overflow: ellipsis — is that also intended for Title? If the title overflows the row without ellipsis, text will just disappear.


5. Missing component tests

The navigationUtils unit tests are thorough. However there are no Playwright/component tests for:

  • CorpusDiscussionsView — thread click pushing ?thread=, back button clearing it
  • Mobile menu button visibility/click behavior in CorpusLandingView / CorpusDetailsView
  • Bottom-fixed ReplyForm in ThreadDetail (behavior change from inline → bottom)

Per the CLAUDE.md pattern, component behavior changes typically warrant .ct.tsx coverage.


Minor Notes

  • Corpuses.tsx:2699 — Removing the three debug console.log calls is good cleanup. Consider whether documentsViewMode logging was serving an active debug need (the commit removes it silently).
  • useUnifiedMentionSearch.ts — The null-document filter is a good defensive fix; worth a brief comment explaining why documents can be null to prevent the filter being removed as "dead code" in future.
  • CHANGELOG ordering — The new ## [Unreleased] - 2026-02-11 section is inserted below the existing unreleased block rather than at the top. If the intent is a chronological order within Unreleased, that's fine, but it may be worth confirming this is intentional.
  • updateDetailViewParam also switches to push semantics — means navigating Landing→Details→(browser back) returns to Landing rather than exiting corpus. That seems intentional and is the right call; just making sure it's acknowledged.

Positives

  • Removing inlineSelectedThreadIdAtom and relying on URL state is exactly the right architectural move for this codebase.
  • updateThreadParam function is cleanly written and its JSDoc is clear.
  • Test coverage for updateThreadParam and the changed updateTabParam semantics is solid.
  • The MobileMenuButton pattern (conditional on prop, accessible aria-label, data-testid) is consistent with the rest of the codebase.
  • ComposerContainer move enabling :focus-within to expand the toolbar is a nice CSS-in-JS pattern.
  • Null-document filter in useUnifiedMentionSearch prevents a real crash path.

@claude
Copy link

claude bot commented Feb 19, 2026

PR Review: Thread URL-Driven State + Mobile Menu

Overall this is a solid refactor that correctly follows the CentralRouteManager architecture. A few issues worth addressing before merge.


Issues

1. CHANGELOG Structure Bug (Minor)

The new ## [Unreleased] - 2026-02-11 block was inserted in the middle of an existing unreleased section, splitting it and creating duplicate ### Changed headers under the Feb 11 block. The current CHANGELOG.md (lines 71–130) now shows:

## [Unreleased] - 2026-02-11

### Changed          ← new (Mobile Navigation) ✅
...

### Added            ← new (Mobile Menu) ✅
...

### Changed          ← DUPLICATE – Import/Export block that was already there ⚠️
### Added            ← also already there
### Fixed            ← also already there

The Feb 11 block appears to have been inserted between the ### Fixed items (lines 65–69) and the ### Changed (line 92) of the pre-existing unreleased block, splitting that older block and causing the duplicate.

Fix: Move the new ## [Unreleased] - 2026-02-11 block to appear immediately before the prior unreleased block, so it doesn't divide existing content. Or merge the new entries into the existing block as appropriate.


2. PR Description Inaccuracy

The PR description says:

Updated handleBackToList() to use navigate(-1) for proper history navigation

The actual implementation uses updateThreadParam(location, navigate, null), which pushes a new history entry with the thread param cleared – it does not call navigate(-1). This is arguably better (direct-linked thread URLs won't push users back to wherever they came from externally), but the description should be corrected.


3. Silent Quote Feature Removal in ReplyForm.tsx

The PR removes the Quote button and inline QuotedMessage block from the reply composer without any mention in the PR description or changelog. This is a feature regression (users can no longer quote a parent message in their reply). If intentional, it should be documented.


4. updateTabParam Push Semantics – Verify UX Intent

Switching tabs previously used replace: true, so the browser history stack stayed clean. With push semantics, clicking through 3–4 tabs creates 3–4 history entries, meaning pressing Browser Back navigates through each previously-visited tab rather than leaving the page/corpus.

This is likely the intended behavior given the PR goals, but it's worth confirming there's no scenario (e.g., the user opens a corpus, clicks several tabs, then presses Back expecting to return to the corpus list) where this becomes confusing. The tab-specific param clearing (thread, message) on tab switch is a good companion to this change.


Positive Changes

  • Dead code cleanup: Removal of console.log debug statements from Corpuses.tsx, the onViewModeChange callback, and discussionInThreadView state is clean and correct.

  • Correct reactive var initialization: selectedThreadId is initialized to null in cache.ts (line 523), so the currentThreadId !== null guard in CorpusDiscussionsView behaves correctly with no edge cases around undefined.

  • CentralRouteManager integration: The updateTabParam now clearing thread and message params on tab switch is the right complement to URL-driven thread state – without this, switching away from the Discussions tab would leave stale state in the URL.

  • onOpenMobileMenu wiring is complete: The prop flows correctly from Corpuses.tsx:2206,2325CorpusHomeCorpusLandingView / CorpusDetailsView, and the button is conditionally rendered only when the prop is provided.

  • Null safety in useUnifiedMentionSearch.ts: Filtering out annotation edges with null documents prevents downstream crashes when structurally-stored annotations (which have document_id=NULL) appear in mention search results.

  • Reply UX improvement: Moving the reply form to a bottom composer (rather than rendering it inline in MessageTree at the reply target's depth) simplifies the component tree and avoids deeply-nested reply boxes.

  • Test coverage: The new updateThreadParam tests are thorough, and the updated updateTabParam tests correctly verify the push (not replace) semantics and tab-specific param clearing.

  • Accessibility on MobileMenuButton: focus-visible outline, aria-label, and data-testid are all present.

JSv4 added 2 commits February 19, 2026 07:28
- MessageComposer: Focus editor before assertions that depend on
  :focus-within state (character count, send button interaction)
- ReplyForm: Remove assertion for "replying to" text that was replaced
  by CornerDownRight icon in the new compact reply context design
@codecov
Copy link

codecov bot commented Feb 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@claude
Copy link

claude bot commented Feb 19, 2026

PR Review: Thread Selection URL Migration

Overall this is a well-structured PR. The migration from Jotai atom state to URL-driven state follows the CentralRouteManager pattern correctly, and the removal of the debug console.log statements is a welcome cleanup. A few concerns worth addressing before merge:


Concern 1: handleBackToList creates double history entries (potential UX issue)

File: frontend/src/components/discussions/CorpusDiscussionsView.tsx

The PR description says handleBackToList was updated to use navigate(-1), but the actual implementation pushes a new history entry via updateThreadParam(location, navigate, null):

const handleBackToList = useCallback(() => {
  updateThreadParam(location, navigate, null); // pushes new entry
}, [location, navigate]);

This creates an undesirable history stack: if a user clicks a thread (pushes ?thread=xyz), then clicks the in-app "Back" button (pushes ?thread= cleared), pressing browser back now returns them to the thread detail — then another browser back gets to the list. The in-app back and browser back become desynchronised.

The cleaner pattern would be navigate(-1) (to pop the entry the thread click pushed) or passing { replace: true } when clearing the thread. This is what the PR description says was intended.


Concern 2: updateTabParam push semantics may cause tab-history accumulation

File: frontend/src/utils/navigationUtils.ts

Changing updateTabParam from { replace: true } to push semantics means every tab switch now creates a browser history entry. A user clicking through four tabs (Home → Documents → Discussions → Feed) creates four stacked history entries. Pressing browser Back then cycles through tabs rather than navigating away from the corpus, which is likely unexpected.

The primary goal of this PR (thread back navigation) only requires updateThreadParam and updateDetailViewParam to use push semantics. Keeping updateTabParam at { replace: true } would deliver the intended benefit without the tab-history side-effect. Worth revisiting this trade-off deliberately.


Bug: Thread title overflow in ThreadDetail

File: frontend/src/components/threads/ThreadDetail.tsx

The refactored Title has white-space: nowrap and flex-shrink: 0 but no overflow protection:

const Title = styled.h1`
  font-size: 1.25rem;
  flex-shrink: 0;
  white-space: nowrap;  // no overflow: hidden / text-overflow: ellipsis
`;

For threads with long titles (which is common) this will overflow the header row. Either add overflow: hidden; text-overflow: ellipsis; to Title or remove flex-shrink: 0 so it participates in flex sizing. The Description span next to it already has proper ellipsis treatment — Title should too.


Minor: MobileMenuButton inside breadcrumb <nav>

File: frontend/src/components/corpuses/CorpusHome/CorpusLandingView.tsx

<CenteredBreadcrumbs aria-label="Breadcrumb navigation">
  ...
  {onOpenMobileMenu && (
    <MobileMenuButton onClick={onOpenMobileMenu} aria-label="Open navigation menu">
      <MoreVertical />
    </MobileMenuButton>
  )}
</CenteredBreadcrumbs>

Placing a navigation-menu button inside a <nav aria-label="Breadcrumb navigation"> element means screen readers will announce it as a breadcrumb control. The button should be a sibling of CenteredBreadcrumbs (or inside its own container) rather than a child to preserve correct landmark semantics.


Minor: Description semantic change in ThreadDetail

The thread description was changed from <p> to <span> for the inline-ellipsis layout. If the description is a full sentence/paragraph, <p> is the more semantically appropriate element (or an <h1> + <p> combination). Consider keeping it as <p> with the ellipsis styles, or wrapping it in a separate row if the inline layout is needed.


What's working well

  • The CentralRouteManager Phase 2 pattern is applied consistently: URL → CentralRouteManager → reactive var → component re-render.
  • updateTabParam now correctly clears thread and message params on tab switch, preventing stale state.
  • Removal of six debug console.log statements from Corpuses.tsx.
  • Good unit test coverage for the new updateThreadParam (5 new test cases).
  • The null-document filter added in useUnifiedMentionSearch.ts is a good defensive guard.
  • The MessageComposer progressive-disclosure design (toolbar hidden until focus) is a clean UX improvement.
  • Removing the inline ReplyForm from MessageTree and consolidating it at the bottom of ThreadDetail simplifies the component hierarchy.

JSv4 added 2 commits February 19, 2026 18:36
…eview

- handleBackToList uses navigate(-1) to pop history instead of pushing
- updateTabParam uses replace semantics to avoid history accumulation
- ThreadDetail Title gets overflow/ellipsis protection for long titles
- MobileMenuButton moved outside breadcrumb <nav> for correct landmarks
@claude
Copy link

claude bot commented Feb 20, 2026

PR Review: Migrate thread selection to URL-driven state

Good overall direction — migrating to URL-driven state and removing the Jotai atom for inline thread selection aligns well with the CentralRouteManager architecture. The cleanup of the dead onViewModeChange callback and the debug console.log removal are both welcome.

That said, there are a few issues worth addressing.


Bug: updateTabParam test/implementation mismatch (will fail)

This is the most important issue. The tests for updateTabParam were updated to expect push semantics (no { replace: true }), but the implementation in navigationUtils.ts line 2633 still calls navigate(..., { replace: true }).

Implementation (line 824-833, unchanged context in diff):

// Replace (not push) so tab switches don't accumulate history entries
navigate({ search: searchParams.toString() }, { replace: true });

Test (updated to expect push):

// Should push, not replace, for proper browser back/forward
expect(mockNavigate).toHaveBeenCalledWith({ search: "tab=discussions" });
// Verify no second argument (no { replace: true })
expect(mockNavigate.mock.calls[0]).toHaveLength(1);

The CHANGELOG also says both updateTabParam and updateDetailViewParam now push, but only updateDetailViewParam was actually changed (line 2678). These unit tests will fail as written.

Either: (a) remove { replace: true } from the updateTabParam implementation to match the tests and CHANGELOG, or (b) revert the test to expect replace semantics (and update the CHANGELOG). Pick one and make them consistent.


Potential UX issue: navigate(-1) may leave corpus when thread URL is opened directly

// CorpusDiscussionsView.tsx
const handleBackToList = useCallback(() => {
  navigate(-1);
}, [navigate]);

If a user navigates directly to a URL like /c/user/my-corpus?tab=discussions&thread=abc (e.g., from a notification link), pressing "Back" calls navigate(-1), which pops to the previous browser history entry — likely a completely different site or page, not the thread list. The user would be ejected from the corpus view.

The previous approach (clearing ?thread= param with replace) was safer here. A middle-ground: call updateThreadParam(location, navigate, null) to pop to the list, and only use navigate(-1) if you can confirm the previous history entry was the thread list (which you can't reliably). Alternatively, document this as a known limitation.


Inline styles deviate from styled-components pattern

Both CorpusLandingView.tsx and CorpusDetailsView.tsx add wrapper divs with inline style attributes:

<div
  style={{
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between",
    width: "100%",
  }}
>

The rest of these files use named styled-components for everything. These one-off layout wrappers should be extracted as styled components (e.g., HeaderRow, BreadcrumbRow) for consistency. The CLAUDE.md and existing patterns in styles.ts support this.


Minor: CHANGELOG structure has cosmetic noise

The CHANGELOG diff has many + lines that are just blank lines added around section headers (e.g., before -, bullets, and code blocks). While not a functional issue, it creates a very noisy diff that makes it harder to review what actually changed. Worth considering whether the CHANGELOG reformatting was intentional.


What's working well

  • Removing inlineSelectedThreadIdAtom simplifies state — one source of truth in the URL is correct
  • updateThreadParam follows the existing updateTabParam/updateMessageParam pattern consistently
  • Deriving discussionInThreadView from useReactiveVar(selectedThreadId) in Corpuses.tsx instead of a separate state variable is clean
  • Clearing thread and message params on tab switch prevents stale URL state — that's a good catch
  • The MobileMenuButton styled component is consistent with the existing styles.ts patterns and correctly uses mediaQuery.mobile so it hides on desktop
  • Filtering out annotations with null documents in useUnifiedMentionSearch.ts is a defensive improvement
  • ThreadDetail reply refactor (moving reply form to bottom composer instead of inline in the tree) simplifies the component significantly
  • Test coverage for updateThreadParam is thorough

JSv4 added 2 commits February 19, 2026 20:07
…omponent

- handleBackToList uses updateThreadParam with replace instead of navigate(-1),
  safe for direct-link navigation where no thread-list history entry exists
- Extract inline-styled wrapper divs to HeaderRow styled component in styles.ts
- updateThreadParam accepts optional { replace } options parameter
- Fix CHANGELOG to reflect updateTabParam retains replace semantics
@claude
Copy link

claude bot commented Feb 20, 2026

PR Review: Migrate thread selection to URL-driven state with browser history support

Overall this is a clean, well-structured migration. The architecture aligns correctly with the CentralRouteManager Phase 2 pattern, and the test coverage for the new updateThreadParam utility is solid. A few issues worth addressing before merging:


Issues

1. CHANGELOG entry is inaccurate about updateTabParam

The CHANGELOG says:

Tab switching now pushes browser history: updateTabParam() and updateDetailViewParam() ... now push new history entries instead of replacing

But looking at the actual diff, updateTabParam intentionally keeps replace: true — confirmed both by the inline code comment added in this PR ("Replace (not push) so tab switches don't accumulate history entries") and by the updated test description ("should set tab parameter and replace (not push) history").

Only updateDetailViewParam was changed to push semantics. The CHANGELOG should be corrected to remove updateTabParam from the list of functions that now push history, or clarify that updateTabParam was updated only to clear stale params, not to change push/replace behavior.


2. onOpenMobileMenu may be unconnected at the top level

The prop is threaded through CorpusHome.tsx to CorpusLandingView / CorpusDetailsView, and the button is conditionally rendered only when onOpenMobileMenu is defined. However, the Corpuses.tsx diff does not show any change to pass this prop to <CorpusHome>. If Corpuses.tsx is the sole renderer of CorpusHome and never supplies onOpenMobileMenu, the mobile button will never appear — making the styled component and prop threading dead code.

Please verify whether the prop is wired to an actual sidebar toggle somewhere higher in the tree, or add the missing connection in Corpuses.tsx.


3. Thread title/description truncation with no tooltip

In ThreadDetail.tsx, Title now has white-space: nowrap; overflow: hidden; text-overflow: ellipsis and Description has similar truncation. Neither element has a title attribute to let users read the full text on hover. Consider adding title={thread.title} and title={thread.description} to the respective elements, or a tooltip wrapper.


4. Description semantic regression

ThreadDetail.tsx changes Description from a <p> to a <span>. The description field is paragraph-level content; using <p> (block element) is more semantically correct and more accessible to screen readers. If the layout issue driving this change is that a block element breaks a flex row, the fix should be CSS (e.g., display: inline or flex: 1; min-width: 0 on the parent) rather than changing the HTML element.


5. Minor: navigate(-1) on direct URL navigation

handleBackToList calls navigate(-1), which is correct for the primary flow (user clicked a thread, entry pushed, back returns to list). However, if a user arrives at ?thread=123 via a shared link, back will exit to wherever they came from outside the app. This is standard browser behaviour, not a bug, but worth a comment in the code for future maintainers so no one "fixes" it by switching back to explicit param clearing.


6. Minor: if (threadId) falsy check in updateThreadParam

if (threadId) {
  searchParams.set("thread", threadId);
} else { ... }

An empty string "" would take the else branch and clear the thread param. Since thread IDs come from GraphQL and should never be empty strings, this is low-risk. But if (threadId !== null) would more clearly express the intent (distinguish between "set thread" and "clear thread") and align with the TypeScript type string | null.


Positive Highlights

  • The architecture migration is clean — single source of truth in the URL, synced to Apollo reactive var by CentralRouteManager, consumed by components via useReactiveVar. No redundant state.
  • Removal of debug console.log calls from Corpuses.tsx is appreciated.
  • The stale-state prevention (clearing thread and message params on tab switch) is the right call and the tests covering it are thorough.
  • Good explanatory comment left in threadAtoms.ts explaining why the atom was removed and where the state lives now.
  • updateThreadParam tests are comprehensive (set, clear, push vs replace, preserve unrelated params, update existing).
  • Using useReactiveVar(selectedThreadId) in Corpuses.tsx to derive discussionInThreadView is simpler and more correct than the previous callback-based synchronization.

@claude
Copy link

claude bot commented Feb 20, 2026

Code Review

Summary

This is a solid architectural migration that aligns thread selection with the CentralRouteManager URL-driven state pattern, and adds sensible mobile UX improvements. The core refactoring is clean. A few issues to flag before merging.


Issues

1. updateDetailViewParam push semantics creates an asymmetric history problem

In navigationUtils.ts, updateDetailViewParam was changed from replace: true to push semantics. The intent is so browser back works between Landing and Details views — good goal. However, handleBackToLanding in CorpusHome.tsx calls updateDetailViewParam(location, navigate, null), which means the "Back to Overview" button also pushes a new history entry instead of going back.

This creates an asymmetry vs. the thread navigation:

  • Thread: Click thread → push. Click "Back" → replace (removes the thread entry cleanly)
  • Detail view: Click Details → push. Click "Back to Overview" → also pushes (adds another landing entry)

The effect: after toggling Landing → Details → Landing, browser back takes the user to the Details view again rather than wherever they were before visiting the corpus. With thread navigation, the same pattern was correctly handled by using replace on the back action. handleBackToLanding should probably call navigate(-1) (or use replace: true) instead of pushing a fresh landing entry.

2. Missing unit test for updateDetailViewParam behavior change

The function's semantics changed from replace to push, but no unit test was added to document or verify this. Given that tests were added for updateTabParam's new thread/message clearing behavior, the same discipline should apply here. Without a test, a future refactor could silently revert to replace semantics.

3. Tab switch clears thread param via replace — potential UX cliff

When updateTabParam clears the thread param on tab switch, it uses replace: true (by design, to prevent tab history accumulation). The side effect: if a user accidentally clicks the wrong tab while viewing a thread, browser back does not return them to the thread — it takes them to the page before the corpus entirely, since the thread URL was replaced, not pushed. This is arguably acceptable, but it is a behavior change from the previous atom-based state (where tabs did not affect thread state). Worth documenting in a comment or at minimum being aware of during QA.


Nitpicks / Minor

Leftover comment section in threadAtoms.ts

The comment block replacing inlineSelectedThreadIdAtom is useful, but per CLAUDE.md "No dead code" rule the section header // INLINE THREAD VIEW STATE and the explanatory comment block could be removed entirely since the section it describes no longer exists.

updateThreadParam can be simplified

The if (options) { navigate(searchString, options) } else { navigate(searchString) } branch can be replaced with navigate(searchString, options) since React Router's navigate accepts undefined options fine.

ReplyForm test assertion change warrants more coverage

In ReplyForm.ct.tsx, the assertion changed from /replying to/i text to @testuser. The added comment explains the icon change but there is no assertion for the icon itself. Low priority, but if the icon changes again the test still passes.


Positives

  • Removing the debug console.log statements from Corpuses.tsx is a necessary cleanup.
  • The CSS-in-JS ordering fix for MessageComposer (moving ComposerContainer after Toolbar, EditorContainer, Footer, CharacterCount so the &:focus-within nested selectors work correctly) is a proper fix, and the progressive disclosure pattern is a nice UX improvement.
  • The null-document filter in useUnifiedMentionSearch (.filter((edge) => edge.node.document != null)) is a legitimate defensive fix.
  • The handleViewModeChange callback simplification (empty deps array []) is correct since the callback no longer closes over documentsViewMode.
  • MessageTree no longer embeds ReplyForm inline — cleaner separation of concerns.
  • Test coverage for updateThreadParam and updateTabParam clearing behavior is thorough.
  • Mobile menu button is properly mobile-only via mediaQuery.mobile guard and has correct aria-label, focus ring, and keyboard accessibility styling.

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.

2 participants

Comments