Persist thread sidebar visibility (#3390)

This commit is contained in:
Steven Enamakel
2026-06-04 18:58:01 -04:00
committed by GitHub
parent 090c987fff
commit e1d25d8bf4
4 changed files with 46 additions and 9 deletions
+12 -5
View File
@@ -57,6 +57,7 @@ import {
persistReaction,
setActiveThread,
setSelectedThread,
setThreadSidebarVisible,
THREAD_NOT_FOUND_MESSAGE,
updateThreadTitle,
} from '../store/threadSlice';
@@ -187,10 +188,16 @@ const Conversations = ({
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const { threads, selectedThreadId, messages, isLoadingMessages, messagesError, activeThreadId } =
useAppSelector(state => state.thread);
const {
threads,
selectedThreadId,
threadSidebarVisible = false,
messages,
isLoadingMessages,
messagesError,
activeThreadId,
} = useAppSelector(state => state.thread);
const [showSidebar, setShowSidebar] = useState(false);
const [inputValue, setInputValue] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -1224,7 +1231,7 @@ const Conversations = ({
labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel;
const isSidebar = variant === 'sidebar';
const effectiveShowSidebar = showSidebar;
const effectiveShowSidebar = threadSidebarVisible;
// Stable title resolver used by both the sidebar thread list and the header.
const resolveThreadDisplayTitle = (threadId: string | null): string => {
@@ -1405,7 +1412,7 @@ const Conversations = ({
<button
type="button"
data-analytics-id="chat-header-toggle-sidebar"
onClick={() => setShowSidebar(prev => !prev)}
onClick={() => dispatch(setThreadSidebarVisible(!effectiveShowSidebar))}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 transition-colors"
title={effectiveShowSidebar ? t('chat.hideSidebar') : t('chat.showSidebar')}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -206,8 +206,7 @@ async function renderConversations(preload: Record<string, unknown> = {}) {
return store;
}
/** Click the sidebar toggle so the thread list becomes visible.
* The sidebar starts hidden (showSidebar=false) in this PR. */
/** Click the sidebar toggle so the thread list becomes visible. */
async function openSidebar() {
const toggleBtn = screen.getByTitle('Show sidebar');
await act(async () => {
@@ -219,6 +218,7 @@ async function openSidebar() {
const emptyThreadState = {
threads: [],
selectedThreadId: null,
threadSidebarVisible: false,
activeThreadId: null,
welcomeThreadId: null,
messagesByThreadId: {},
@@ -312,7 +312,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
// Covers line 906: const effectiveShowSidebar = showSidebar;
// Covers line 906: const effectiveShowSidebar = threadSidebarVisible;
// Covers line 941: <div className="flex-1 overflow-y-auto"> (always rendered in page mode)
it('renders the Threads sidebar header in page mode', async () => {
await act(async () => {
@@ -326,6 +326,26 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
expect(screen.getByText('Threads')).toBeInTheDocument();
});
it('restores and updates the persisted thread sidebar visibility', async () => {
let renderedStore: ReturnType<typeof buildStore> | undefined;
await act(async () => {
renderedStore = await renderConversations({
thread: { ...emptyThreadState, threadSidebarVisible: true },
});
});
expect(screen.getByText('Threads')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByTitle('Hide sidebar'));
});
await waitFor(() => {
expect(screen.queryByText('Threads')).not.toBeInTheDocument();
});
expect(renderedStore?.getState().thread.threadSidebarVisible).toBe(false);
});
// Covers line 941 empty branch
it('shows the General empty message when the default bucket has no threads', async () => {
await act(async () => {
+5 -1
View File
@@ -138,7 +138,11 @@ const persistedNotificationReducer = persistReducer(notificationPersistConfig, n
// they were instead of falling through to "create a new thread". The
// thread list and per-thread message caches are re-fetched from the core
// on boot, so we deliberately don't persist them.
const threadPersistConfig = { key: 'thread', storage, whitelist: ['selectedThreadId'] };
const threadPersistConfig = {
key: 'thread',
storage,
whitelist: ['selectedThreadId', 'threadSidebarVisible'],
};
const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer);
// Persist only previously persisted mascot appearance fields plus the custom
+6
View File
@@ -11,6 +11,7 @@ export const THREAD_NOT_FOUND_MESSAGE = 'This thread is no longer available.';
interface ThreadState {
threads: Thread[];
selectedThreadId: string | null;
threadSidebarVisible: boolean;
activeThreadId: string | null;
/**
* @deprecated — welcome-agent replaced by Joyride walkthrough. Always null
@@ -27,6 +28,7 @@ interface ThreadState {
const initialState: ThreadState = {
threads: [],
selectedThreadId: null,
threadSidebarVisible: false,
activeThreadId: null,
welcomeThreadId: null,
messagesByThreadId: {},
@@ -337,6 +339,9 @@ const threadSlice = createSlice({
setActiveThread: (state, action: { payload: string | null }) => {
state.activeThreadId = action.payload;
},
setThreadSidebarVisible: (state, action: PayloadAction<boolean>) => {
state.threadSidebarVisible = action.payload;
},
clearStaleThread: (state, action: PayloadAction<string>) => {
const threadId = action.payload;
state.threads = state.threads.filter(thread => thread.id !== threadId);
@@ -459,6 +464,7 @@ export const {
setSelectedThread,
clearSelectedThread,
setActiveThread,
setThreadSidebarVisible,
clearStaleThread,
clearAllThreads,
resetThreadCachesPreservingSelection,