From 95047af5b952eedc6235a3cd649f40989368ffc9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 15:29:20 -0700 Subject: [PATCH 1/2] fix(organizations): align sidebar loading and chat actions --- .../chats-section/chats-section.test.tsx | 133 ++++++++-- .../chats-section/chats-section.tsx | 248 +++++++++++------- .../hooks/use-organization-chat-actions.ts | 128 +++++++++ .../organization-sidebar.tsx | 30 +-- .../app/o/[organizationId]/layout.test.tsx | 54 +++- apps/sim/app/o/[organizationId]/layout.tsx | 14 +- .../app/o/[organizationId]/prefetch.test.ts | 180 +++++++++++++ apps/sim/app/o/[organizationId]/prefetch.ts | 38 +++ .../app/workspace/[workspaceId]/prefetch.ts | 52 +--- .../context-menu/context-menu.test.tsx | 60 +++++ .../components/context-menu/context-menu.tsx | 21 +- apps/sim/hooks/use-context-menu.ts | 14 +- apps/sim/lib/workspaces/list.ts | 5 +- .../sim/lib/workspaces/seed-workspace-list.ts | 38 +++ 14 files changed, 808 insertions(+), 207 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts create mode 100644 apps/sim/app/o/[organizationId]/prefetch.test.ts create mode 100644 apps/sim/app/o/[organizationId]/prefetch.ts create mode 100644 apps/sim/lib/workspaces/seed-workspace-list.ts diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx index e4b3856a5d3..06cd846e637 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' const hoverState = vi.hoisted(() => ({ isOpen: false })) +const mockRequestJson = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) vi.mock('next/link', () => ({ default: ({ @@ -25,7 +28,7 @@ vi.mock('next/link', () => ({ ), })) -vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu', () => ({ useHoverMenu: () => ({ isOpen: hoverState.isOpen, open: vi.fn(), @@ -37,6 +40,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ })) import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section' +import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({ id: `chat-${index + 1}`, @@ -59,6 +63,8 @@ beforeEach(() => { disconnect() {} } ) + vi.clearAllMocks() + mockRequestJson.mockResolvedValue({ success: true }) hoverState.isOpen = false queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue() @@ -83,9 +89,7 @@ async function render(props: Partial[0]> = {}) { isLoading={false} isCollapsed={false} pathname={null} - menuOpenHref={null} - onContextMenu={() => {}} - onMoreClick={() => {}} + organizationId='org-1' {...props} /> @@ -94,11 +98,19 @@ async function render(props: Partial[0]> = {}) { } describe('ChatsSection', () => { - it('lists every chat with no paging control', async () => { + it('shows five chats with the workspace-style See more and See less controls', async () => { await render() - + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) + const more = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See more' + )! + await act(async () => more.click()) expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) - expect(container.textContent).not.toContain('See more') + const less = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See less' + )! + await act(async () => less.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) }) it('marks the chat on the current route active', async () => { @@ -110,17 +122,108 @@ describe('ChatsSection', () => { expect(other?.className).not.toContain('surface-active') }) - it('reports the row href when its options button is pressed', async () => { - const onMoreClick = vi.fn() - await render({ onMoreClick }) + it.each([false, true])('renames via the options menu with collapsed=%s', async (isCollapsed) => { + hoverState.isOpen = isCollapsed + await render({ isCollapsed }) + const button = + document.body.querySelector( + 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + ) ?? document.body.querySelector('[aria-label="Chat options"]')! + await act(async () => button.click()) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + expect(rename).toBeDefined() + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + expect(input).not.toBeNull() + expect(input.value).toMatch(/^Chat /) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Planning' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.objectContaining({ method: 'PATCH' }), + expect.objectContaining({ body: { title: 'Planning' } }) + ) + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) + + it('rolls back only the organization list when rename fails', async () => { + const pending = Promise.withResolvers<{ success: boolean }>() + mockRequestJson.mockReturnValueOnce(pending.promise) + const key = mothershipChatKeys.organizationList('org-1') + queryClient.setQueryData(key, [{ id: 'chat-1', name: 'Chat 1' }]) + const workspaceKey = mothershipChatKeys.list('workspace-1') + queryClient.setQueryData(workspaceKey, [{ id: 'workspace-chat', name: 'Workspace chat' }]) + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => action.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Pending title' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Pending title' }]) + await act(async () => pending.reject(new Error('Rename rejected'))) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Chat 1' }]) + expect(queryClient.getQueryData(workspaceKey)).toEqual([ + { id: 'workspace-chat', name: 'Workspace chat' }, + ]) + expect(input.value).toBe('Chat 1') + expect(input.disabled).toBe(false) + }) - const button = container.querySelector( - 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + it('cancels rename on Escape without a mutation', async () => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) ) - await act(async () => button?.click()) + expect(mockRequestJson).not.toHaveBeenCalled() + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) - expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2') - expect(prefetchQuery).not.toHaveBeenCalled() + it.each([ + ['Pin', { pinned: true }], + ['Mark as unread', { isUnread: true }], + ])('offers %s for organization chats', async (label, body) => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === label)! + await act(async () => action.click()) + expect(mockRequestJson).toHaveBeenCalledWith(expect.objectContaining({ method: 'PATCH' }), { + params: { chatId: 'chat-1' }, + body, + }) }) it.each([false, true])( diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 351afadfeae..c4301745f87 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,19 +1,30 @@ 'use client' -import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { useState } from 'react' +import { + Chip, + ChipInput, + chipVariants, + cn, + DropdownMenuItem, + Loader, + OverflowText, + Skeleton, +} from '@sim/emcn' import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' -import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions' import { ChatNavigationLink, + CollapsedChatFlyoutItem, CollapsedSidebarMenu, SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { SIDEBAR_ITEM_GAP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' /** Stands in for a chip row while the list loads, so it carries no margin either. */ function ChatRowSkeleton() { @@ -28,11 +39,19 @@ interface ChatRowProps { chat: OrganizationChat isCurrentRoute: boolean isMenuOpen: boolean - onContextMenu: (e: React.MouseEvent, href: string) => void - onMoreClick: (e: React.MouseEvent, href: string) => void + onContextMenu: (e: React.MouseEvent, chatId: string) => void + onMorePointerDown: () => void + onMoreClick: (e: React.MouseEvent, chatId: string) => void } -function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) { +function ChatRow({ + chat, + isCurrentRoute, + isMenuOpen, + onContextMenu, + onMorePointerDown, + onMoreClick, +}: ChatRowProps) { /** * The trailing slot fits one glyph, and the dot wins over the pin: it reports * transient state (a run in progress, or an unread reply elsewhere), while pinning @@ -46,7 +65,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick chatId={chat.id} isCurrentRoute={isCurrentRoute} className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })} - onContextMenu={(e) => onContextMenu(e, chat.href)} + onContextMenu={(e) => onContextMenu(e, chat.id)} >
@@ -55,7 +74,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'size-[6px] rounded-full transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} style={{ backgroundColor: chat.isActive ? '#EAB308' : 'var(--brand-accent)' }} /> @@ -65,20 +84,21 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'absolute size-[12px] text-[var(--text-icon)] transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} /> )}
diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 331473db5b0..bcb6145d12f 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -4,18 +4,19 @@ import type { ReactNode } from 'react' import { authMockFns } from '@sim/testing' +import { dehydrate } from '@tanstack/react-query' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetOrganizationSurfaceContext, mockWorkspaceChrome, - mockPrefetchUserProfile, + mockPrefetchOrganizationSidebar, mockUseSession, } = vi.hoisted(() => ({ mockGetOrganizationSurfaceContext: vi.fn(), mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children), - mockPrefetchUserProfile: vi.fn(async () => undefined), + mockPrefetchOrganizationSidebar: vi.fn(async () => undefined), mockUseSession: vi.fn(), })) @@ -30,15 +31,15 @@ vi.mock('@/lib/auth/stale-session-recovery', () => ({ vi.mock('@tanstack/react-query', () => ({ HydrationBoundary: ({ children }: { children: ReactNode }) => children, - dehydrate: () => ({}), + dehydrate: vi.fn(() => ({})), })) vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: () => ({}), })) -vi.mock('@/lib/users/prefetch-user-profile', () => ({ - prefetchUserProfile: mockPrefetchUserProfile, +vi.mock('@/app/o/[organizationId]/prefetch', () => ({ + prefetchOrganizationSidebar: mockPrefetchOrganizationSidebar, })) vi.mock('next/headers', () => ({ @@ -80,7 +81,10 @@ const SURFACE_CONTEXT = { describe('OrganizationLayout', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } }) + mockGetSession.mockResolvedValue({ + user: { id: 'viewer-1' }, + session: { id: 'session-1', activeOrganizationId: 'active-org' }, + }) mockUseSession.mockReturnValue({ data: { user: { id: 'viewer-1' } }, isPending: false }) }) @@ -94,6 +98,7 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/login?callbackUrl=%2Fo%2Forg-1') expect(mockGetOrganizationSurfaceContext).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders the surface for a member and seeds the chrome from the collapse cookie', async () => { @@ -106,7 +111,12 @@ describe('OrganizationLayout', () => { const html = renderToStaticMarkup(element) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') - expect(mockPrefetchUserProfile).toHaveBeenCalledWith({}, 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + 'active-org' + ) expect(html).toContain('Organization child') expect(html).not.toContain('Stop impersonating') expect(mockWorkspaceChrome).toHaveBeenCalledWith( @@ -118,7 +128,7 @@ describe('OrganizationLayout', () => { it('shows the shared impersonation banner above organization content', async () => { const session = { user: { id: 'viewer-1', name: 'QA Member', email: 'member@example.com' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, } mockGetSession.mockResolvedValue(session) mockUseSession.mockReturnValue({ data: session, isPending: false }) @@ -132,6 +142,12 @@ describe('OrganizationLayout', () => { ) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + null + ) expect(html).toContain('Impersonating QA Member (member@example.com)') expect(html).toContain('Stop impersonating') expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child')) @@ -140,7 +156,7 @@ describe('OrganizationLayout', () => { it('does not use the impersonating admin to enter an organization outside the rollout', async () => { mockGetSession.mockResolvedValue({ user: { id: 'customer-member' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, }) mockGetOrganizationSurfaceContext.mockResolvedValue({ ...SURFACE_CONTEXT, @@ -158,6 +174,7 @@ describe('OrganizationLayout', () => { 'customer-member' ) expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders an explicit denial for a non-member without the surface', async () => { @@ -172,6 +189,7 @@ describe('OrganizationLayout', () => { expect(html).toContain('Organization access denied') expect(html).not.toContain('Secret organization child') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it.each(['owner', 'admin', 'member'])( @@ -190,6 +208,24 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/workspace?redirect=settings') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() } ) + + it('waits for sidebar reads before serializing hydration', async () => { + const ready = Promise.withResolvers() + mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT) + mockPrefetchOrganizationSidebar.mockReturnValue(ready.promise) + const pending = OrganizationLayout({ + children: null, + params: Promise.resolve({ organizationId: 'org-1' }), + }) + await vi.waitFor(() => expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledOnce(), { + interval: 1, + }) + expect(dehydrate).not.toHaveBeenCalled() + ready.resolve() + await pending + expect(dehydrate).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 04f5b1129b3..fb4b66e85bd 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -2,13 +2,14 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' -import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied' import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar' +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -43,16 +44,19 @@ export default async function OrganizationLayout({ const [context, cookieStore] = await Promise.all([ getOrganizationSurfaceContext(organizationId, session.user.id), cookies(), - /* The rail's footer renders the viewer, so the profile is layout data: seeded - here it paints hydrated, and a page hydrating the same key beneath finds it - populated rather than an empty query it cannot fill during render. */ - prefetchUserProfile(queryClient, session.user.id), ]) if (!context) { return } if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + await prefetchOrganizationSidebar( + queryClient, + organizationId, + { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + getActiveOrganizationId(session) + ) + const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( diff --git a/apps/sim/app/o/[organizationId]/prefetch.test.ts b/apps/sim/app/o/[organizationId]/prefetch.test.ts new file mode 100644 index 00000000000..679b8c88e24 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { dehydrate, hydrate, QueryClient, QueryObserver } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListOrganizationChats, mockListWorkspacesForViewer, mockGetUserProfile } = vi.hoisted( + () => ({ + mockListOrganizationChats: vi.fn(), + mockListWorkspacesForViewer: vi.fn(), + mockGetUserProfile: vi.fn(), + }) +) + +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + listOrganizationChats: { execute: mockListOrganizationChats }, +})) +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: mockListWorkspacesForViewer, +})) +vi.mock('@/lib/users/queries', () => ({ getUserProfile: mockGetUserProfile })) +vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' +import { userProfileKeys } from '@/hooks/queries/current-user-data' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' +import { workspaceKeys } from '@/hooks/queries/workspace' + +const PRINCIPAL: SessionPrincipal = { kind: 'session', userId: 'viewer', sessionId: 'session' } +const CHAT = { + id: 'chat', + title: 'Project notes', + updatedAt: '2026-01-02T00:00:00.000Z', + activeStreamId: null, + lastSeenAt: '2026-01-01T00:00:00.000Z', + pinned: true, + deletedAt: null, +} +const WORKSPACES = { + workspaces: [ + { + id: 'workspace', + name: 'Engineering', + ownerId: 'viewer', + organizationId: 'route-org', + workspaceMode: 'organization', + permissions: 'read', + }, + ], + lastActiveWorkspaceId: 'workspace', + pinnedWorkspaceIds: ['workspace'], + creationPolicy: null, +} +const CHAT_KEY = mothershipChatKeys.organizationList('route-org', 'active') + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function prefetch(client: QueryClient) { + return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org') +} + +describe('organization sidebar hydration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListOrganizationChats.mockResolvedValue([CHAT]) + mockListWorkspacesForViewer.mockResolvedValue(WORKSPACES) + mockGetUserProfile.mockResolvedValue({ id: 'viewer', name: 'Ada', email: 'ada@example.test' }) + }) + + it('hydrates the current viewer’s routed org chats and keeps workspace metadata intact', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + + expect(mockListOrganizationChats).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { organizationId: 'route-org', scope: 'active' }, + }) + expect(mockListWorkspacesForViewer).toHaveBeenCalledWith({ + userId: 'viewer', + activeOrganizationId: 'active-org', + scope: 'active', + }) + expect(client.getQueryData(CHAT_KEY)).toEqual([ + { + id: 'chat', + name: 'Project notes', + updatedAt: new Date(CHAT.updatedAt), + isActive: false, + isUnread: true, + isPinned: true, + deletedAt: null, + }, + ]) + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' }) + expect(client.getQueryData(mothershipChatKeys.organizationList('active-org'))).toBeUndefined() + expect(client.getQueryData(mothershipChatKeys.list('workspace'))).toBeUndefined() + expect( + client.getQueryData(mothershipChatKeys.organizationList('route-org', 'archived')) + ).toBeUndefined() + }) + + it('starts the independent reads together and waits for all before dehydration', async () => { + const chats = Promise.withResolvers<(typeof CHAT)[]>() + const workspaces = Promise.withResolvers() + mockListOrganizationChats.mockReturnValue(chats.promise) + mockListWorkspacesForViewer.mockReturnValue(workspaces.promise) + const client = makeClient() + let finished = false + const pending = prefetch(client).then(() => { + finished = true + }) + + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + expect(mockListWorkspacesForViewer).toHaveBeenCalledOnce() + expect(mockGetUserProfile).toHaveBeenCalledOnce() + expect(dehydrate(client).queries).toHaveLength(0) + expect(finished).toBe(false) + chats.resolve([CHAT]) + await chats.promise + expect(finished).toBe(false) + workspaces.resolve(WORKSPACES) + await pending + expect(dehydrate(client).queries).toHaveLength(3) + }) + + it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => { + mockListOrganizationChats.mockResolvedValue([]) + mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] }) + const client = makeClient() + await prefetch(client) + expect(client.getQueryData(CHAT_KEY)).toEqual([]) + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + }) + + it('omits a denied chat read from hydration without losing successful sidebar reads', async () => { + mockListOrganizationChats.mockRejectedValue(new Error('Forbidden')) + const server = makeClient() + await expect(prefetch(server)).resolves.toBeUndefined() + const client = makeClient() + hydrate(client, dehydrate(server)) + expect(client.getQueryState(CHAT_KEY)).toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + }) + + it('does not suppress client recovery when the workspace read fails', async () => { + mockListWorkspacesForViewer.mockRejectedValue(new Error('Unavailable')) + const client = makeClient() + await expect(prefetch(client)).resolves.toBeUndefined() + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + expect(client.getQueryData(CHAT_KEY)).toHaveLength(1) + }) + + it('does not fetch chats again when a fresh hydrated observer mounts', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + const fetchChats = vi.fn().mockResolvedValue([]) + const observer = new QueryObserver(client, { + queryKey: CHAT_KEY, + queryFn: fetchChats, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }) + const unsubscribe = observer.subscribe(() => {}) + expect(observer.getCurrentResult().isPending).toBe(false) + expect(observer.getCurrentResult().data).toHaveLength(1) + expect(fetchChats).not.toHaveBeenCalled() + unsubscribe() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/prefetch.ts b/apps/sim/app/o/[organizationId]/prefetch.ts new file mode 100644 index 00000000000..0ed2be1f62f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.ts @@ -0,0 +1,38 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { QueryClient } from '@tanstack/react-query' +import { listOrganizationChats } from '@/lib/copilot/chat/organization-chats' +import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' +import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mapChat, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' + +/** + * Settles the org sidebar's reads before hydration, using the client keys and + * mappers. Chat access goes through the same authorized operation as the API; + * failed reads stay out of hydration so the client can retry them. + */ +export async function prefetchOrganizationSidebar( + queryClient: QueryClient, + organizationId: string, + principal: SessionPrincipal, + activeOrganizationId: string | null +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), + queryFn: async () => { + const chats = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope: 'active' }, + }) + return chats.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + seedWorkspaceList(queryClient, principal.userId, activeOrganizationId), + prefetchUserProfile(queryClient, principal.userId), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 95a2735c120..45ae8b71bbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,14 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import type { QueryClient } from '@tanstack/react-query' -import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' -import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' +import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, @@ -17,7 +15,6 @@ import { } from '@/hooks/queries/mothership-chats' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' -import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' import { WORKSPACE_HOST_CONTEXT_STALE_TIME, @@ -40,51 +37,6 @@ export function prefetchWorkspaceHostContext( }) } -const logger = createLogger('WorkspacePrefetch') - -/** - * Seeds the viewer's workspace list, which the switcher reads. - * - * Seeded rather than prefetched so the empty-list case can decline to create a - * cache entry at all: the route's default-workspace creation path must run on - * the client, and an entry — even an empty one — would suppress it. Expressing - * that as an absent seed also keeps a routine state out of the error channel, - * where it read as a failure rather than as "nothing to seed". - */ -async function seedWorkspaceList( - queryClient: QueryClient, - userId: string, - activeOrganizationId: string | null -): Promise { - try { - const payload = await listWorkspacesForViewer({ - userId, - activeOrganizationId, - scope: 'active', - }) - if (payload.workspaces.length === 0) return - /** - * Parsing through the route contract's response schema strips the same - * server-only fields `requestJson` strips on the client, guaranteeing the - * seeded shape is identical to a client fetch. - */ - queryClient.setQueryData( - workspaceKeys.list('active'), - normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) - ) - } catch (error) { - /** - * Swallowed rather than rethrown — this read is an optimization; the layout - * renders fine without it and the client fetch reaches the route instead. - * Logged because contract drift between the read and the response schema - * would otherwise degrade silently into every viewer waterfalling. - */ - logger.warn('Workspace list seed failed; client will fetch', { - error: getErrorMessage(error), - }) - } -} - /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, * workspace, and viewer-profile reads for a workspace and stores them under the diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx index 2b73e2dc4a7..b4292375be0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -84,6 +84,66 @@ afterEach(() => { }) describe('sidebar context menu dismissal', () => { + it('keeps the exiting menu inert after handing focus to the rename input', () => { + const getComputedStyle = window.getComputedStyle + /** JSDOM snapshots styles; Radix Presence requires a live exit-animation name. */ + const animationStyles = vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const styles = getComputedStyle(element) + if (element.getAttribute('role') === 'menu') { + Object.defineProperty(styles, 'animationName', { + get: () => (element.getAttribute('data-state') === 'closed' ? 'menu-exit' : 'menu-enter'), + }) + } + return styles + }) + const menuRef = { current: null as HTMLDivElement | null } + const renameInputRef = { current: null as HTMLInputElement | null } + const onRenameBlur = vi.fn() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + function renderRenameMenu(isOpen: boolean) { + root?.render( + <> + + renderRenameMenu(false)} + onRename={() => renameInputRef.current?.focus()} + renameInputRef={renameInputRef} + showDelete={false} + showDuplicate={false} + /> + + ) + } + + try { + act(() => renderRenameMenu(true)) + const menu = menuRef.current! + const renameItem = menu.querySelector('[role="menuitem"]')! + expect(menu.hasAttribute('inert')).toBe(false) + const pointerMove = new MouseEvent('pointermove', { bubbles: true, cancelable: true }) + Object.defineProperty(pointerMove, 'pointerType', { value: 'mouse' }) + act(() => renameItem.dispatchEvent(pointerMove)) + expect(document.activeElement).toBe(renameItem) + + act(() => renameItem.click()) + + expect(menu.isConnected).toBe(true) + expect(menu.getAttribute('data-state')).toBe('closed') + expect(menu.hasAttribute('inert')).toBe(true) + expect(document.activeElement).toBe(renameInputRef.current) + expect(onRenameBlur).not.toHaveBeenCalled() + } finally { + animationStyles.mockRestore() + } + }) + it('stays open when a surrounding menu takes focus back', () => { const onClose = vi.fn() renderMenu(onClose) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 447b69008e2..20fa333126e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -33,6 +33,7 @@ interface ContextMenuProps { position: { x: number; y: number } menuRef: React.RefObject onClose: () => void + onCopyLink?: () => void onOpenInNewTab?: () => void openInNewTabLabel?: string openInNewTabPosition?: 'first' | 'last' @@ -56,7 +57,7 @@ interface ContextMenuProps { onCreateFolder?: () => void onDuplicate?: () => void onExport?: () => void - onDelete: () => void + onDelete?: () => void /** * Closes the item rather than deleting it — for tabs, where the destructive * action is "close this one", not "delete it forever". Named for the item so @@ -118,6 +119,7 @@ export function ContextMenu({ position, menuRef, onClose, + onCopyLink, onOpenInNewTab, openInNewTabLabel = 'Open in new tab', openInNewTabPosition = 'first', @@ -169,6 +171,7 @@ export function ContextMenu({ selectedCount = 1, }: ContextMenuProps) { const hasActionsAboveDestructive = + onCopyLink || (showOpenInNewTab && onOpenInNewTab) || (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) || @@ -182,7 +185,7 @@ export function ContextMenu({ (showExport && onExport) const hasDestructiveSection = (showLeave && onLeave) || - showDelete || + (showDelete && onDelete) || (showCloseTab && onCloseTab) || onCloseOtherTabs || onCloseTabsToRight @@ -214,6 +217,7 @@ export function ContextMenu({ side='bottom' sideOffset={4} className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]' + inert={!isOpen} onFocusOutside={(e) => { const target = e.target if (target instanceof Element && target.closest('[role="menu"]')) { @@ -242,6 +246,17 @@ export function ContextMenu({ {openInNewTabLabel} )} + {onCopyLink && ( + { + onCopyLink() + onClose() + }} + > + + Copy link + + )} {showMarkAsRead && onMarkAsRead && ( )} - {showDelete && ( + {showDelete && onDelete && ( { diff --git a/apps/sim/hooks/use-context-menu.ts b/apps/sim/hooks/use-context-menu.ts index d87a3258d4e..75fb0393a0a 100644 --- a/apps/sim/hooks/use-context-menu.ts +++ b/apps/sim/hooks/use-context-menu.ts @@ -29,20 +29,21 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { const menuRef = useRef(null) const dismissPreventedRef = useRef(false) + const openMenuAt = useCallback((nextPosition: ContextMenuPosition) => { + setPosition(nextPosition) + setIsOpen(true) + }, []) + const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - const x = e.clientX - const y = e.clientY - - setPosition({ x, y }) - setIsOpen(true) + openMenuAt({ x: e.clientX, y: e.clientY }) onContextMenu?.(e) }, - [onContextMenu] + [onContextMenu, openMenuAt] ) const closeMenu = useCallback(() => { @@ -84,6 +85,7 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { position, menuRef, handleContextMenu, + openMenuAt, closeMenu, preventDismiss, } diff --git a/apps/sim/lib/workspaces/list.ts b/apps/sim/lib/workspaces/list.ts index bba77f0e6a3..163276dc6ff 100644 --- a/apps/sim/lib/workspaces/list.ts +++ b/apps/sim/lib/workspaces/list.ts @@ -112,9 +112,8 @@ async function buildWorkspacesWithInviteFlags( * the workspace creation policy. * * Unlike the route, this performs no writes — no default-workspace creation and - * no orphaned-workflow repair. It exists for the workspace layout's sidebar - * prefetch, which only runs after host-context authorization has proven the - * viewer already has at least one accessible workspace. + * no orphaned-workflow repair. Sidebar prefetch leaves empty lists uncached so + * the client can still reach the route's default-workspace creation path. */ export async function listWorkspacesForViewer(params: { userId: string diff --git a/apps/sim/lib/workspaces/seed-workspace-list.ts b/apps/sim/lib/workspaces/seed-workspace-list.ts new file mode 100644 index 00000000000..b9a1c6064d7 --- /dev/null +++ b/apps/sim/lib/workspaces/seed-workspace-list.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { QueryClient } from '@tanstack/react-query' +import { listWorkspacesContract } from '@/lib/api/contracts/workspaces' +import { listWorkspacesForViewer } from '@/lib/workspaces/list' +import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' +import { workspaceKeys } from '@/hooks/queries/workspace' + +const logger = createLogger('WorkspaceListPrefetch') + +/** + * Leaves empty workspace lists uncached so the client reaches the route's + * default-workspace creation path. + */ +export async function seedWorkspaceList( + queryClient: QueryClient, + userId: string, + activeOrganizationId: string | null +): Promise { + try { + const payload = await listWorkspacesForViewer({ + userId, + activeOrganizationId, + scope: 'active', + }) + if (payload.workspaces.length === 0) return + /** Strip server-only fields to match the client response. */ + queryClient.setQueryData( + workspaceKeys.list('active'), + normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) + ) + } catch (error) { + /** Keep optional prefetch failures from blocking the layout. */ + logger.warn('Workspace list seed failed; client will fetch', { + error: getErrorMessage(error), + }) + } +} From 1d4ddfe59d3c36ad1c90ce06b306465661ae26ef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 15:44:51 -0700 Subject: [PATCH 2/2] fix(organizations): preserve chat context during pagination --- .../chats-section/chats-section.test.tsx | 30 +++++++++++++++++++ .../chats-section/chats-section.tsx | 13 ++++---- .../organization-sidebar.tsx | 1 + 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx index 06cd846e637..f6dd1e44e2b 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -122,6 +122,36 @@ describe('ChatsSection', () => { expect(other?.className).not.toContain('surface-active') }) + it('keeps a bookmarked chat visible when collapsing expanded history', async () => { + await render({ pathname: CHATS[5].href }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) + expect(container.querySelector(`a[href="${CHATS[5].href}"]`)?.className).toContain( + 'surface-active' + ) + const more = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See more' + )! + await act(async () => more.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + const less = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See less' + )! + await act(async () => less.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) + expect(container.querySelector(`a[href="${CHATS[5].href}"]`)).not.toBeNull() + }) + + it('derives the visible range from the route without retaining automatic expansion', async () => { + await render({ pathname: CHATS[7].href }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + expect(container.textContent).not.toContain('See more') + expect(container.textContent).not.toContain('See less') + + await render({ pathname: null }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) + expect(container.textContent).toContain('See more') + }) + it.each([false, true])('renames via the options menu with collapsed=%s', async (isCollapsed) => { hoverState.isOpen = isCollapsed await render({ isCollapsed }) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index c4301745f87..d447427e017 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -128,7 +128,10 @@ export function ChatsSection({ }: ChatsSectionProps) { const actions = useOrganizationChatActions({ organizationId, chats }) const { menu, hover, rename, selectedChat } = actions - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const [requestedCount, setRequestedCount] = useState(PAGE_SIZE) + const minimumCount = Math.max(PAGE_SIZE, chats.findIndex((chat) => chat.href === pathname) + 1) + const visibleCount = Math.min(chats.length, Math.max(requestedCount, minimumCount)) + const hasMore = chats.length > visibleCount const menuOpenChatId = menu.isOpen ? selectedChat?.id : null const saveRename = () => { void rename.saveRename() @@ -217,16 +220,14 @@ export function ChatsSection({ /> ) )} - {chats.length > PAGE_SIZE && ( + {(hasMore || visibleCount > minimumCount) && ( - setVisibleCount( - chats.length > visibleCount ? visibleCount + PAGE_SIZE : PAGE_SIZE - ) + setRequestedCount(hasMore ? visibleCount + PAGE_SIZE : PAGE_SIZE) } > - {chats.length > visibleCount ? 'See more' : 'See less'} + {hasMore ? 'See more' : 'See less'} )} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index af1fe939ef5..126004e808d 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -266,6 +266,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { /> {searchAccess.memberScoped && (