From d1e66277dbde082cf740341ff3dbd94b5bf46220 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 12:17:32 +0100 Subject: [PATCH 1/2] docs: gate list-header content on both workspace navigation lists openChannels and openThreads hold what is bound to the workspace's panels. A channel or thread opened over another one in the same panel is not in them, so a thread stacked over a channel is on screen while openThreads is empty. It always sits over a bound item, so "something is open beside the list" is either list being non-empty. The migration guide and BC-099 now say so and point to isChannelActive / isThreadActive for whether a given channel or thread is on screen. The vite example and the collapsible-sidebar recipe show the sidebar toggle while either list is non-empty. --- ai-docs/ai-migration-v14-v15.md | 24 +++++++++---------- ai-docs/v15-breaking-changes.md | 4 ++-- .../layout/collapsible-sidebar/views.tsx | 13 +++++----- .../SwitchableChannelNavigation.tsx | 4 ++-- examples/vite/src/Sidebar/HeaderContent.tsx | 21 +++++++--------- 5 files changed, 30 insertions(+), 36 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index f7f3508ce..357184157 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1690,7 +1690,11 @@ Where you pass the props depends on who renders the header: Both headers are exported. -To keep the v14 behavior of showing the content only next to an open channel or thread (a sidebar toggle, for example), make the content return `null` otherwise: read `openChannels` / `openThreads` from `useWorkspaceNavigation()`. `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills these lists; without one they stay empty, so gate on your own selection state instead. +To keep showing the content only while something is open beside the list (a sidebar toggle, for example), make it return `null` otherwise. Read the workspace from `useWorkspaceNavigation()`: + +- `openChannels` / `openThreads` list the channels and threads bound to the workspace's panels. A channel or thread opened over another one in the same panel (stacked) is not in them, but it always sits over one that is. So "something is open" is either list being non-empty; `openThreads` alone misses a thread stacked over a channel. +- To ask whether a given channel or thread is on screen, use `isChannelActive(cid)` / `isThreadActive(threadId)`, which follow what each panel shows. +- `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills all of these. Without one the lists stay empty, so gate on your own selection state instead. ```tsx // v14 @@ -1708,24 +1712,18 @@ import { // `SidebarToggle` stands for a control of your own. const SidebarToggle = () => ; -// Shown only while a channel is open, as in v14. -const SidebarToggleWhileChannelOpen = () => { - const { openChannels } = useWorkspaceNavigation(); - return openChannels.length > 0 ? : null; -}; - -// Shown only while a thread is open, as in v14. -const SidebarToggleWhileThreadOpen = () => { - const { openThreads } = useWorkspaceNavigation(); - return openThreads.length > 0 ? : null; +// Shown only while a channel or thread is open beside the list. +const SidebarToggleWhileContentOpen = () => { + const { openChannels, openThreads } = useWorkspaceNavigation(); + return openChannels.length > 0 || openThreads.length > 0 ? : null; }; const ChannelListHeaderWithToggle = () => ( - + ); const ThreadListHeaderWithToggle = () => ( - + ); // Declared once, so WithComponents gets the same object on every render. diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index 5ffe36ef9..a05e0cd38 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -2315,12 +2315,12 @@ const CustomThreadHeader = () => { - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. - where the SDK renders the header, the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. - where the app renders the header itself (for example `ChannelListHeader` above a standalone `ChannelList`), it passes the props directly; the override key does not reach that header. - - to keep the v14 gate, read `useWorkspaceNavigation().openChannels` / `.openThreads` in the content component and return `null` while it is empty. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these lists; without one they stay empty, so an app without either gates on its own selection state. + - to show the content only while something is open beside the list, read `useWorkspaceNavigation()` in the content component and return `null` while both `openChannels` and `openThreads` are empty. The lists hold what is bound to the workspace's panels: a channel or thread stacked over another one in a panel is not in them, but always sits over one that is, so checking one list alone misses it (a thread stacked over a channel is not in `openThreads`). Whether a given channel or thread is on screen is `isChannelActive(cid)` / `isThreadActive(threadId)`. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these; without one the lists stay empty, so an app without either gates on its own selection state. - Evidence: - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?` - ticket: REACT-1449 - - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileChannelOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` + - examples: `examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx` passes `EndContent={SidebarToggleWhileContentOpen}` to `ChannelListHeader`; `examples/vite/src/App.tsx` registers `ThreadListHeaderWithSidebarToggle` (`examples/vite/src/Sidebar/HeaderContent.tsx`) as `ThreadListHeader` - Docs impact: - `03-ui/03-components/01-channel-list-ui.md`, `03-ui/03-components/08-thread-list-ui.md`, `03-ui/03-components/12-overriding-components.md`, `06-component-reference/02-channel/04-componentcontext.md`, `06-component-reference/07-thread-list/01-threadlist.md`, `05-ui-cookbooks/10-layout/04-collapsible-sidebar.md`, `05-ui-cookbooks/03-search/03-mentions-inbox.md`, `08-release-guides/01-upgrade-to-v15.md` - Example needed: yes (the guide section has one) diff --git a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx index 0f32984ab..2d2f99ccd 100644 --- a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx +++ b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx @@ -20,16 +20,17 @@ export const layouts: LayoutDescriptor[] = [ { id: 'channels', slots: ['channel', 'thread'] }, ]; -// The toggle, only while a channel is open beside the list. With nothing open, collapsing the -// sidebar would leave an empty screen. -const SidebarToggleWhileChannelOpen = () => { - const { openChannels } = useWorkspaceNavigation(); - return openChannels.length > 0 ? : null; +// The toggle, only while a channel or thread is open beside the list. With nothing open, +// collapsing the sidebar would leave an empty screen. A channel or thread stacked over another is +// not in `openChannels` / `openThreads`, but always sits over one that is. +const SidebarToggleWhileContentOpen = () => { + const { openChannels, openThreads } = useWorkspaceNavigation(); + return openChannels.length > 0 || openThreads.length > 0 ? : null; }; // The default channel list header, with the toggle at its end. const ChannelListHeaderWithToggle = () => ( - + ); // Declared once, so WithComponents gets the same object on every render. diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index 53a50fc94..2592c3ff9 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -25,7 +25,7 @@ import { useStateStore, } from 'stream-chat-react'; -import { SidebarToggleWhileChannelOpen } from '../Sidebar/HeaderContent.tsx'; +import { SidebarToggleWhileContentOpen } from '../Sidebar/HeaderContent.tsx'; import { ScrollToActiveChannelButton } from './ScrollToActiveChannelButton'; // Human-readable labels for each channel list, keyed by the paginator id set up in App.tsx. @@ -222,7 +222,7 @@ export const SwitchableChannelNavigation = () => { return (
- + {!isActive && activePaginator && ( // Expose the active paginator through ChannelListContext (as the SDK's ChannelLists diff --git a/examples/vite/src/Sidebar/HeaderContent.tsx b/examples/vite/src/Sidebar/HeaderContent.tsx index 30e977db9..05b98db13 100644 --- a/examples/vite/src/Sidebar/HeaderContent.tsx +++ b/examples/vite/src/Sidebar/HeaderContent.tsx @@ -15,22 +15,17 @@ export const HeaderStartWithSidebarToggle = () => ( ); -/** The sidebar toggle while a channel is open beside the channel list. With nothing open, - * collapsing the sidebar would leave an empty screen. */ -export const SidebarToggleWhileChannelOpen = () => { - const { openChannels } = useWorkspaceNavigation(); - return openChannels.length > 0 ? : null; -}; - -/** The sidebar toggle while a thread is open beside the thread list. With nothing open, - * collapsing the sidebar would leave an empty screen. */ -export const SidebarToggleWhileThreadOpen = () => { - const { openThreads } = useWorkspaceNavigation(); - return openThreads.length > 0 ? : null; +/** The sidebar toggle while a channel or thread is open beside the list. With nothing open, + * collapsing the sidebar would leave an empty screen. `openChannels` / `openThreads` hold what is + * bound to the panels; a channel or thread stacked over one is not in them, but always sits over + * something that is, so checking both lists covers it. */ +export const SidebarToggleWhileContentOpen = () => { + const { openChannels, openThreads } = useWorkspaceNavigation(); + return openChannels.length > 0 || openThreads.length > 0 ? : null; }; /** The thread list header, registered as the `ThreadListHeader` override, with the sidebar toggle * at its end. */ export const ThreadListHeaderWithSidebarToggle = () => ( - + ); From 40f2e109d8ffe8cfd0cd731689c0e709eed9c98d Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Nov 2026 12:27:10 +0100 Subject: [PATCH 2/2] docs: note that panels opened with another kind are in neither navigation list A panel opened with a userProfile, even with a channel stacked over it, is not in openChannels or openThreads. The guide and BC-099 say so and point layouts with such panels to useLayoutViewState(); the example comments no longer claim a stacked item always sits over a listed one. --- ai-docs/ai-migration-v14-v15.md | 3 ++- ai-docs/v15-breaking-changes.md | 2 +- .../src/recipes/layout/collapsible-sidebar/views.tsx | 5 +++-- examples/vite/src/Sidebar/HeaderContent.tsx | 7 ++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 357184157..b5d8a7e4c 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -1692,7 +1692,8 @@ Both headers are exported. To keep showing the content only while something is open beside the list (a sidebar toggle, for example), make it return `null` otherwise. Read the workspace from `useWorkspaceNavigation()`: -- `openChannels` / `openThreads` list the channels and threads bound to the workspace's panels. A channel or thread opened over another one in the same panel (stacked) is not in them, but it always sits over one that is. So "something is open" is either list being non-empty; `openThreads` alone misses a thread stacked over a channel. +- `openChannels` / `openThreads` list the channels and threads that the workspace's panels were opened with. Something opened over another item in the same panel (stacked) is not in them. In a workspace whose panels open with channels and threads only, a stacked item always sits over one that is in the lists, so "something is open" is either list being non-empty; `openThreads` alone misses a thread stacked over a channel. +- A panel opened with another kind, such as a `userProfile` panel, is in neither list, even with a channel stacked over it. If your layout opens such panels, check the layout instead: `useLayoutViewState()` from `stream-chat-react/slot-layout` returns each slot's binding (`slotBindings`) and stacked layers (`slotLayers`). - To ask whether a given channel or thread is on screen, use `isChannelActive(cid)` / `isThreadActive(threadId)`, which follow what each panel shows. - `ChatView` (or your own `WorkspaceNavigationProvider`, [section 4](#4-selecting-channels-and-opening-threads-workspace-navigation)) fills all of these. Without one the lists stay empty, so gate on your own selection state instead. diff --git a/ai-docs/v15-breaking-changes.md b/ai-docs/v15-breaking-changes.md index a05e0cd38..b2203893a 100644 --- a/ai-docs/v15-breaking-changes.md +++ b/ai-docs/v15-breaking-changes.md @@ -2315,7 +2315,7 @@ const CustomThreadHeader = () => { - `ChannelListHeader` and `ThreadListHeader` take `StartContent` (before the title) and `EndContent` (after the title), as `ChannelHeader` / `ThreadHeader` do. Neither has a default. Types: `ChannelListHeaderProps`, `ThreadListHeaderProps`. - where the SDK renders the header, the props go through the header's override key: register a component that renders the default header with them. `ChannelListHeader` is read by `ChannelNavigation`; `ThreadListHeader` is a new key, read by `ThreadList`. `ThreadListHeader` is now exported. - where the app renders the header itself (for example `ChannelListHeader` above a standalone `ChannelList`), it passes the props directly; the override key does not reach that header. - - to show the content only while something is open beside the list, read `useWorkspaceNavigation()` in the content component and return `null` while both `openChannels` and `openThreads` are empty. The lists hold what is bound to the workspace's panels: a channel or thread stacked over another one in a panel is not in them, but always sits over one that is, so checking one list alone misses it (a thread stacked over a channel is not in `openThreads`). Whether a given channel or thread is on screen is `isChannelActive(cid)` / `isThreadActive(threadId)`. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these; without one the lists stay empty, so an app without either gates on its own selection state. + - to show the content only while something is open beside the list, read `useWorkspaceNavigation()` in the content component and return `null` while both `openChannels` and `openThreads` are empty. The lists hold the channels and threads the workspace's panels were opened with: an item stacked over another one in a panel is not in them. Where panels open with channels and threads only, a stacked item always sits over one that is, so checking one list alone misses it (a thread stacked over a channel is not in `openThreads`). A panel opened with another kind (a `userProfile` panel, even with a channel stacked over it) is in neither list; such layouts check `useLayoutViewState()` (`slotBindings`, `slotLayers`) from `stream-chat-react/slot-layout` instead. Whether a given channel or thread is on screen is `isChannelActive(cid)` / `isThreadActive(threadId)`. `ChatView` or an app's own `WorkspaceNavigationProvider` fills these; without one the lists stay empty, so an app without either gates on its own selection state. - Evidence: - `release-v14:src/components/ChannelList/ChannelListHeader.tsx:15` `{channel && HeaderEndContent && }`; `release-v14:src/components/Threads/ThreadList/ThreadListHeader.tsx:12` `{activeThread && HeaderEndContent && }`; `release-v14:src/context/ComponentContext.tsx:303` - `master:src/components/ChannelList/ChannelListHeader.tsx` and `master:src/components/Threads/ThreadList/ThreadListHeader.tsx` (`StartContent` / `EndContent` props); `master:src/components/Threads/ThreadList/ThreadList.tsx` reads `ThreadListHeader` from `useComponentContext()`; `master:src/context/ComponentContext.tsx` `ThreadListHeader?` diff --git a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx index 2d2f99ccd..6d17ef4b0 100644 --- a/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx +++ b/examples/cookbook/src/recipes/layout/collapsible-sidebar/views.tsx @@ -21,8 +21,9 @@ export const layouts: LayoutDescriptor[] = [ ]; // The toggle, only while a channel or thread is open beside the list. With nothing open, -// collapsing the sidebar would leave an empty screen. A channel or thread stacked over another is -// not in `openChannels` / `openThreads`, but always sits over one that is. +// collapsing the sidebar would leave an empty screen. `openChannels` / `openThreads` hold the +// channels and threads the panels were opened with, so checking both covers a thread stacked over +// a channel. const SidebarToggleWhileContentOpen = () => { const { openChannels, openThreads } = useWorkspaceNavigation(); return openChannels.length > 0 || openThreads.length > 0 ? : null; diff --git a/examples/vite/src/Sidebar/HeaderContent.tsx b/examples/vite/src/Sidebar/HeaderContent.tsx index 05b98db13..764fbc455 100644 --- a/examples/vite/src/Sidebar/HeaderContent.tsx +++ b/examples/vite/src/Sidebar/HeaderContent.tsx @@ -16,9 +16,10 @@ export const HeaderStartWithSidebarToggle = () => ( ); /** The sidebar toggle while a channel or thread is open beside the list. With nothing open, - * collapsing the sidebar would leave an empty screen. `openChannels` / `openThreads` hold what is - * bound to the panels; a channel or thread stacked over one is not in them, but always sits over - * something that is, so checking both lists covers it. */ + * collapsing the sidebar would leave an empty screen. `openChannels` / `openThreads` hold the + * channels and threads the panels were opened with, so checking both covers a thread stacked over + * a channel. A panel opened with a user profile is in neither list, and the toggle stays hidden + * next to it. */ export const SidebarToggleWhileContentOpen = () => { const { openChannels, openThreads } = useWorkspaceNavigation(); return openChannels.length > 0 || openThreads.length > 0 ? : null;