Skip to content

Mobile partner messaging fixes#3459

Open
marcusljf wants to merge 5 commits intomainfrom
program-partner-messages
Open

Mobile partner messaging fixes#3459
marcusljf wants to merge 5 commits intomainfrom
program-partner-messages

Conversation

@marcusljf
Copy link
Collaborator

@marcusljf marcusljf commented Feb 11, 2026

For both programs and partners a few issues were happening on smaller screens:

  • When clicking "new message" and it shows the partner/program drawer, after selecting a partner/program, nothing else shows. It should open the compose window, with the message input in focus.
  • When you select and existing conversation, the far panel opens over the conversation and you need to close it to see the thread.
  • Program only When you click the menu button in the top left, it opens the nav panel, but everything is hidden until you click on the partner icon again, and it takes you back to the program overview. on larger screens this makes sense, but on mobile we need to show the full nav.
CleanShot.2026-02-11.at.14.26.21.mp4

Summary by CodeRabbit

  • New Features
    • Composer auto-focuses for "new message" via URL (works on mobile).
    • Right-hand messages panel auto-opens/closes based on viewport width.
    • Switching programs or partners opens the main messages view and routes to composer entry state.
    • Selecting a thread/program preserves target thread selection and cleans up the URL trigger automatically.

For both programs and partners a few issues were happening on smaller screens:
- When clicking "new message" and it shows the partner/program drawer, after selecting a partner/program, nothing else shows. It should open the compose window, with the message input in focus.
- When you select and existing conversation, the far panel opens over the conversation and you need to close it to see the thread.
- `Program only` When you click the menu button in the top left, it opens the nav panel, but everything is hidden until you click on the partner icon again, and it takes you back to the program overview. on larger screens this makes sense, but on mobile we need to show the full nav.
@vercel
Copy link
Contributor

vercel bot commented Feb 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Building Building Preview Feb 18, 2026 5:45am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

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

Walkthrough

Adds URL-driven composer autofocus via ?new=1, responsive right-panel open/close based on viewport width, passes an autoFocusComposer prop to MessagesPanel, and syncs/selects a targetThreadId when program/partner selection navigates to messages.

Changes

Cohort / File(s) Summary
Partners dashboard messages
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx
Add media-query and navigation hooks; derive active thread from messages; compute shouldAutoFocusComposer from ?new=1 and clean it from URL; default right-panel closed and toggle by width (1082px); set targetThreadId and currentPanel="main" when selecting program and navigate with ?new=1.
App dashboard messages
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx
Introduce useMediaQuery and routing hooks; derive active thread from partnerMessages; add shouldAutoFocusComposer from ?new=1, auto-clean param, default right-panel closed and responsive toggle; set/clear targetThreadId on partner changes and navigate with ?new=1.
Shared messages context & UI
apps/web/ui/messages/messages-context.tsx, apps/web/ui/messages/messages-list.tsx, apps/web/ui/messages/messages-panel.tsx
Extend MessagesContext with targetThreadId and setTargetThreadId; MessagesList sets targetThreadId before opening a thread; MessagesPanel gains autoFocusComposer?: boolean and sets MessageInput autofocus to `!isMobile
Sidebar/layout
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
Add useMediaQuery usage and mobile-drawer viewport detection; change program-area detection logic to account for mobile drawer viewport sensitivities.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Router as Router/Navigation
    participant Layout as Messages Layout
    participant Page as Page Client
    participant Panel as MessagesPanel
    participant Input as MessageInput

    User->>Router: select program/partner
    Router->>Layout: navigate to /messages/{id}?new=1
    Layout->>Layout: setTargetThreadId(id), setCurrentPanel("main")
    Layout->>Page: render page-client for selection
    Page->>Page: read searchParams (?new=1) and viewport width
    Page->>Router: router.replace(...) to remove ?new=1
    Page->>Panel: render with autoFocusComposer=true (if param present)
    Panel->>Input: render with autoFocus = !isMobile || autoFocusComposer
    Input->>User: focus composer input
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

"I hopped through code with nimble feet,
A ?new=1 carrot, oh so sweet,
Panels that open by width's tune,
Composer wakes beneath the moon,
Messages bloom — quick, neat!" 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 'Mobile partner messaging fixes' accurately summarizes the main changes across multiple components addressing mobile UI/UX issues in the messaging system for partners and programs.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch program-partner-messages

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

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (2)

119-122: URL cleanup drops all search params — align with the program-side implementation

router.replace(\/messages/${programSlug}`)unconditionally strips every search param on the URL, not justnew. The analogous effect in the program-side page ([partnerId]/page-client.tsx, lines 106-114) uses URLSearchParamsto delete only"new"` and preserves the rest. Adopt the same pattern here to avoid silently losing any other params that may be present now or in future.

♻️ Proposed fix
+ import { usePathname } from "next/navigation";
  // ...
+ const pathname = usePathname();

  useEffect(() => {
    if (!shouldAutoFocusComposer) return;
-   router.replace(`/messages/${programSlug}`);
+   const nextSearchParams = new URLSearchParams(searchParams.toString());
+   nextSearchParams.delete("new");
+   const nextSearch = nextSearchParams.toString();
+   router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname);
- }, [programSlug, router, shouldAutoFocusComposer]);
+ }, [pathname, router, searchParams, shouldAutoFocusComposer]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/`(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
around lines 119 - 122, The current useEffect in page-client.tsx unconditionally
calls router.replace(`/messages/${programSlug}`) which strips all search params;
change it to read the current URLSearchParams, delete only the "new" param, and
then call router.replace with the same pathname (`/messages/${programSlug}`)
plus the preserved search string so other params remain intact; update the
useEffect that references shouldAutoFocusComposer, programSlug, and router to
build the new query via URLSearchParams and pass the resulting search (or empty
string) to router.replace.

114-117: Width effect overrides manual panel toggle on any resize

The width dependency means that every resize event that changes width forcibly resets isRightPanelOpen to width >= 1082, regardless of whether the user previously toggled the panel intentionally. A user on a 1200px viewport who deliberately closes the panel will have it reopened immediately if they resize by even a single pixel.

If preserving intentional user toggles matters, consider tracking "user has explicitly set the panel" to suppress the automatic override:

♻️ Suggested approach
- const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
+ const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
+ const [isPanelUserControlled, setIsPanelUserControlled] = useState(false);

  useEffect(() => {
    if (typeof width !== "number") return;
-   setIsRightPanelOpen(width >= 1082);
+   if (!isPanelUserControlled) {
+     setIsRightPanelOpen(width >= 1082);
+   }
  }, [programSlug, width]);

  // Reset user-controlled flag when navigating to a different program
+ useEffect(() => {
+   setIsPanelUserControlled(false);
+ }, [programSlug]);

Then set setIsPanelUserControlled(true) in any click handler that calls setIsRightPanelOpen.

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

In
`@apps/web/app/`(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
around lines 114 - 117, The effect that auto-sets isRightPanelOpen on width
changes (useEffect referencing width, programSlug, setIsRightPanelOpen)
currently overrides any user toggle; add a new state flag (e.g.,
isPanelUserControlled with setter setIsPanelUserControlled) and update the
resize effect to only auto-set isRightPanelOpen when isPanelUserControlled is
false, and include isPanelUserControlled in the effect dependencies; also ensure
every user toggle handler that calls setIsRightPanelOpen sets
setIsPanelUserControlled(true) so subsequent resizes won’t overwrite the user’s
explicit choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx:
- Around line 101-104: The effect that watches width and partnerId resets
isRightPanelOpen on every resize and thus overrides user manual toggles; change
the logic in the useEffect (the one using width, partnerId and calling
setIsRightPanelOpen) to only auto-set when there has been no user toggle (for
example track a manual toggle flag like hasManualRightPanelToggle or initialize
isRightPanelOpen to null/undefined and only set it from width when it is unset),
and ensure the manual toggle handler updates that flag so subsequent resizes do
not clobber the user's choice.

---

Nitpick comments:
In
`@apps/web/app/`(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx:
- Around line 119-122: The current useEffect in page-client.tsx unconditionally
calls router.replace(`/messages/${programSlug}`) which strips all search params;
change it to read the current URLSearchParams, delete only the "new" param, and
then call router.replace with the same pathname (`/messages/${programSlug}`)
plus the preserved search string so other params remain intact; update the
useEffect that references shouldAutoFocusComposer, programSlug, and router to
build the new query via URLSearchParams and pass the resulting search (or empty
string) to router.replace.
- Around line 114-117: The effect that auto-sets isRightPanelOpen on width
changes (useEffect referencing width, programSlug, setIsRightPanelOpen)
currently overrides any user toggle; add a new state flag (e.g.,
isPanelUserControlled with setter setIsPanelUserControlled) and update the
resize effect to only auto-set isRightPanelOpen when isPanelUserControlled is
false, and include isPanelUserControlled in the effect dependencies; also ensure
every user toggle handler that calls setIsRightPanelOpen sets
setIsPanelUserControlled(true) so subsequent resizes won’t overwrite the user’s
explicit choice.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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)
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx (1)

68-72: targetThreadId stores a slug here but a UUID in the program-side layout.

In the program-side layout (messages/layout.tsx), setTargetThreadId(id) receives a partner UUID, while here setTargetThreadId(slug) receives a program slug. The field name targetThreadId suggests an ID, but it's actually a generic "active thread key" that matches against group.id / activeId. This works correctly but could be confusing for future maintainers.

Consider renaming to something like targetThreadKey if you revisit this area, but not blocking.

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

In `@apps/web/app/`(ee)/partners.dub.co/(dashboard)/messages/layout.tsx around
lines 68 - 72, The code uses targetThreadId to store either a program slug or a
partner UUID which is confusing; rename the state and setter to a neutral name
like targetThreadKey (update the state variable, setter setTargetThreadKey and
all callers such as setSelectedProgramSlug where you call
setTargetThreadId(slug)) and update every comparison/usage that matches against
group.id or activeId to use targetThreadKey instead so the meaning is
consistent; ensure prop and handler names (e.g., setSelectedProgramSlug,
setTargetThreadKey, any layout handlers) and any URL/navigation logic
(router.push calls) are updated to reference the new identifier name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx:
- Around line 104-107: The effect that auto-sets panel open state should not
unconditionally run on every width change; update the logic in the useEffect
(the effect that currently reads width and calls setIsRightPanelOpen) to respect
a user toggle flag or to run only when partnerId changes: add a new state like
userHasToggled (initially false), set userHasToggled = true inside the manual
toggle handler that calls setIsRightPanelOpen, and change the effect to only
auto-set when userHasToggled is false (or change its dependency array to only
[partnerId] so it runs on partner change only); reference the existing
useEffect, setIsRightPanelOpen, isRightPanelOpen, partnerId and width when
making the changes.
- Around line 101-102: The right-panel opens after initial paint causing a
layout shift because isRightPanelOpen is initialized to false; change the
initialization so the state is computed synchronously from the current viewport
(e.g. use a media-query check in the useState initializer) instead of always
false. Update the isRightPanelOpen / setIsRightPanelOpen initialization to
derive its initial value from a synchronous media check (window.matchMedia or
your useMediaQuery sync value) with proper SSR guarding, leaving the existing
effect that updates it in place.

---

Nitpick comments:
In `@apps/web/app/`(ee)/partners.dub.co/(dashboard)/messages/layout.tsx:
- Around line 68-72: The code uses targetThreadId to store either a program slug
or a partner UUID which is confusing; rename the state and setter to a neutral
name like targetThreadKey (update the state variable, setter setTargetThreadKey
and all callers such as setSelectedProgramSlug where you call
setTargetThreadId(slug)) and update every comparison/usage that matches against
group.id or activeId to use targetThreadKey instead so the meaning is
consistent; ensure prop and handler names (e.g., setSelectedProgramSlug,
setTargetThreadKey, any layout handlers) and any URL/navigation logic
(router.push calls) are updated to reference the new identifier name.

Comment on lines +101 to +102
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const shouldAutoFocusComposer = searchParams.get("new") === "1";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor layout shift on desktop: isRightPanelOpen defaults to false.

On wide screens (≥960px), the right panel will be closed on the initial render, then opened by the effect on lines 104–107 after paint. This may cause a brief visible layout shift. Previously this defaulted to true.

Consider initializing based on width if available synchronously, e.g.:

- const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
+ const [isRightPanelOpen, setIsRightPanelOpen] = useState(
+   typeof width === "number" ? width >= 960 : false,
+ );

Though this depends on whether useMediaQuery provides a synchronous initial value or not.

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

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
around lines 101 - 102, The right-panel opens after initial paint causing a
layout shift because isRightPanelOpen is initialized to false; change the
initialization so the state is computed synchronously from the current viewport
(e.g. use a media-query check in the useState initializer) instead of always
false. Update the isRightPanelOpen / setIsRightPanelOpen initialization to
derive its initial value from a synchronous media check (window.matchMedia or
your useMediaQuery sync value) with proper SSR guarding, leaving the existing
effect that updates it in place.

Comment on lines +104 to +107
useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId, width]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Right panel effect re-fires on every width change, potentially overriding manual user toggle.

If a user manually closes the right panel on a wide screen (≥960px), any subsequent resize event (even a 1px change) will reopen it because the effect unconditionally sets isRightPanelOpen based on width. Similarly, manually opening the panel on a narrow screen gets overridden on resize.

Consider tracking whether the user has manually toggled the panel and skipping the auto-set in that case, or only running this logic on partnerId change (not width).

♻️ Possible approach: only auto-set on partnerId change
  useEffect(() => {
    if (typeof width !== "number") return;
    setIsRightPanelOpen(width >= 960);
-  }, [partnerId, width]);
+  }, [partnerId]); // eslint-disable-line react-hooks/exhaustive-deps

This would set the initial panel state based on current viewport when switching partners, without overriding manual toggles during resizes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId, width]);
useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId]); // eslint-disable-line react-hooks/exhaustive-deps
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
around lines 104 - 107, The effect that auto-sets panel open state should not
unconditionally run on every width change; update the logic in the useEffect
(the effect that currently reads width and calls setIsRightPanelOpen) to respect
a user toggle flag or to run only when partnerId changes: add a new state like
userHasToggled (initially false), set userHasToggled = true inside the manual
toggle handler that calls setIsRightPanelOpen, and change the effect to only
auto-set when userHasToggled is false (or change its dependency array to only
[partnerId] so it runs on partner change only); reference the existing
useEffect, setIsRightPanelOpen, isRightPanelOpen, partnerId and width when
making the changes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 `@apps/web/ui/layout/sidebar/app-sidebar-nav.tsx`:
- Around line 555-556: The first render treats unresolved width as desktop and
causes a one-frame flash; update the guard in app-sidebar-nav.tsx (the
isMobileDrawerViewport computation that uses width and isMobile from the
useMediaQuery hook) to optimistically assume mobile when width is undefined (or
otherwise render a skeleton) so that isMobileDrawerViewport becomes true on
initial render, which prevents currentArea from being null and avoids the
layout/data-fetch flash for routes like /program/messages, /program/campaigns,
and /program/payouts/success.

Comment on lines +555 to +556
const isMobileDrawerViewport =
typeof width === "number" ? width < 768 : isMobile;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the useMediaQuery hook definition
rg -n "useMediaQuery" --type=ts --type=tsx -A 5 -B 2 | head -100

Repository: dubinc/dub

Length of output: 81


🏁 Script executed:

# Examine the specific file and context around lines 555-556
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | sed -n '545,570p'

Repository: dubinc/dub

Length of output: 1049


🏁 Script executed:

# Look for the currentArea variable and its dependencies
rg -n "currentArea" apps/web/ui/layout/sidebar/app-sidebar-nav.tsx -B 2 -A 2

Repository: dubinc/dub

Length of output: 1471


🏁 Script executed:

# Find useMediaQuery hook definition and where width/isMobile come from
rg -n "const.*useMediaQuery\|function useMediaQuery\|export.*useMediaQuery" --type=ts -A 10 | head -50

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Search in the same file to see where width and isMobile are declared
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | head -100

Repository: dubinc/dub

Length of output: 3737


🏁 Script executed:

# Look for imports and variable declarations in the file
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | grep -n "import\|width\|isMobile" | head -30

Repository: dubinc/dub

Length of output: 1929


🏁 Script executed:

# Find the useMediaQuery hook implementation in `@dub/ui`
find . -type f -name "*.ts" -o -name "*.tsx" | xargs grep -l "export.*useMediaQuery\|function useMediaQuery" | head -10

Repository: dubinc/dub

Length of output: 96


🏁 Script executed:

# Search more broadly for useMediaQuery definition
rg -n "useMediaQuery" --type=ts -A 20 | grep -A 20 "function\|export\|useState\|initialValue" | head -80

Repository: dubinc/dub

Length of output: 4959


🏁 Script executed:

# Examine the useMediaQuery hook implementation
cat -n ./packages/ui/src/hooks/use-media-query.ts

Repository: dubinc/dub

Length of output: 1738


Potential one-frame flash on mobile for focused program paths.

The useMediaQuery hook initializes with width = undefined and isMobile = false during SSR/hydration. On mobile clients visiting /program/messages/, /program/campaigns/, or /program/payouts/success, the first render evaluates isMobileDrawerViewport = false (since width is not yet a number), causing currentArea to be null. After the hook's useEffect runs and detects actual mobile dimensions, isMobileDrawerViewport flips to true and currentArea becomes "program", enabling data fetches and changing the sidebar layout. This causes a brief visual shift.

Consider initializing the guard optimistically (e.g., treat an unresolved width as mobile-like, or render a skeleton) if this flash is observable in practice.

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

In `@apps/web/ui/layout/sidebar/app-sidebar-nav.tsx` around lines 555 - 556, The
first render treats unresolved width as desktop and causes a one-frame flash;
update the guard in app-sidebar-nav.tsx (the isMobileDrawerViewport computation
that uses width and isMobile from the useMediaQuery hook) to optimistically
assume mobile when width is undefined (or otherwise render a skeleton) so that
isMobileDrawerViewport becomes true on initial render, which prevents
currentArea from being null and avoids the layout/data-fetch flash for routes
like /program/messages, /program/campaigns, and /program/payouts/success.

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