+
+ )
+})
+
+interface ChatsSectionProps {
+ chats: OrganizationChat[]
+ isLoading: boolean
+ isCollapsed: boolean
+ pathname: string | null
+ /** Href of the row whose options menu is open, so it stays highlighted meanwhile. */
+ menuOpenHref: string | null
+ onContextMenu: (e: React.MouseEvent, href: string) => void
+ onMoreClick: (e: React.MouseEvent, href: string) => void
+}
+
+/**
+ * The organization's chats: the first section of the scroll region, so it carries no
+ * section gap — the divider padding above it is the whole distance, exactly as the
+ * workspace sidebar spaces its own Chats. Expanded, a collapsible list of every chat —
+ * no paging, the scroll region carries the length; collapsed, a hover flyout off the
+ * rail glyph.
+ */
+export function ChatsSection({
+ chats,
+ isLoading,
+ isCollapsed,
+ pathname,
+ menuOpenHref,
+ onContextMenu,
+ onMoreClick,
+}: ChatsSectionProps) {
+ const hover = useHoverMenu()
+
+ return (
+
+ {isCollapsed ? (
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
new file mode 100644
index 00000000000..a2ee28fffc9
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts
@@ -0,0 +1 @@
+export { ChatsSection } from './chats-section'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
new file mode 100644
index 00000000000..61a7a80685d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts
@@ -0,0 +1,4 @@
+export { ChatsSection } from './chats-section'
+export { OrganizationFooter } from './organization-footer'
+export { OrganizationHeader } from './organization-header'
+export { WorkspacesRailFlyout } from './workspaces-rail-flyout'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
new file mode 100644
index 00000000000..95078897371
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts
@@ -0,0 +1 @@
+export { OrganizationFooter } from './organization-footer'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
new file mode 100644
index 00000000000..58af5e63d1c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx
@@ -0,0 +1,238 @@
+'use client'
+
+import type { DesktopUpdateState } from '@sim/desktop-bridge'
+import {
+ Chip,
+ chipContentLabelClass,
+ chipPrimaryFillTokens,
+ chipVariants,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuItemLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ OverflowText,
+ Skeleton,
+} from '@sim/emcn'
+import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { SlackIcon } from '@/components/icons'
+import { getAccountSettingsHref } from '@/components/settings/navigation'
+import { getDesktopUpdates } from '@/lib/desktop'
+import { getUserColor } from '@/lib/workspaces/colors'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import {
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_RAIL_CHIP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { useUserProfile } from '@/hooks/queries/user-profile'
+import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
+
+function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean {
+ return state.status === 'available' || state.status === 'downloading' || state.status === 'ready'
+}
+
+function desktopUpdateActionLabel(state: DesktopUpdateState): string {
+ if (state.status === 'downloading') {
+ return state.percent === undefined
+ ? 'Downloading update…'
+ : `Downloading update ${state.percent}%`
+ }
+ return state.status === 'ready' ? 'Restart to update' : 'Update'
+}
+
+/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */
+function DesktopUpdateIcon({ className }: { className?: string }) {
+ return (
+
+ {/* Download's default viewBox is asymmetric around its paths. Center the
+ artwork itself, not merely its SVG box, inside the avatar-sized circle. */}
+
+
+ )
+}
+
+interface OrganizationFooterProps {
+ /**
+ * True while the scroll region above still hides rows beyond its bottom edge —
+ * the same test the divider under the pinned nav applies at the top. The bar's
+ * top rule is drawn only then, so a list that fits meets the footer with no line.
+ */
+ showDivider: boolean
+ isCollapsed: boolean
+ showCollapsedTooltips: boolean
+ onOpenDocs: () => void
+ onJoinSlack: () => void
+}
+
+/**
+ * Pinned bottom bar of the organization sidebar: the viewer's avatar and name,
+ * which open their account settings, plus a help menu. Same two elements and the
+ * same layout as the workspace footer — expanded they share one row with help hard
+ * right, collapsed they stack as icon chips with help on top.
+ *
+ * Collapsed reverses the flex direction instead of reordering the DOM, which keeps
+ * both elements (and the help menu's trigger) alive across a toggle.
+ */
+export function OrganizationFooter({
+ showDivider,
+ isCollapsed,
+ showCollapsedTooltips,
+ onOpenDocs,
+ onJoinSlack,
+}: OrganizationFooterProps) {
+ const { data: profile } = useUserProfile()
+ const updateState = useDesktopUpdateState()
+
+ const name = profile ? profile.name?.trim() || profile.email : ''
+ const updateAvailable = hasAvailableDesktopUpdate(updateState)
+
+ const handleUpdateSelect = () => {
+ const updates = getDesktopUpdates()
+ if (updateState.status === 'ready') {
+ updates?.install()
+ } else if (updateState.status === 'available') {
+ updates?.check()
+ }
+ }
+
+ /**
+ * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a
+ * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`,
+ * which would blank the avatar exactly where it is the only thing left to see.
+ */
+ const avatar = !profile ? (
+
+ ) : profile.image ? (
+
+ ) : (
+
+ {name.charAt(0).toUpperCase()}
+
+ )
+
+ /**
+ * Expanded, the chip hugs its content (`max-w-full` so a long name truncates
+ * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and
+ * `min-w-0` lets the hidden label give up its box so the chip never overflows it.
+ * The name is the button's accessible name — no `aria-label`, which would
+ * override the visible text.
+ */
+ const profileMenu = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ /**
+ * One node across both states; only `fullWidth` changes, so the same Radix menu
+ * survives the transition. `shrink-0` keeps the chip off the avatar while the rail
+ * is briefly narrower than the row — the aside's clip hides it until there is room.
+ */
+ const helpMenu = (
+
+
+
+
+
+
+ {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */}
+
+ {updateAvailable && (
+ <>
+
+
+ {desktopUpdateActionLabel(updateState)}
+
+
+ >
+ )}
+
+
+ Docs
+
+
+
+ Join Slack
+
+
+
+ )
+
+ return (
+
+ {/* Expanded, claims the row's free width so the help button lands hard right.
+ `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the
+ chip's 30px rather than a line box padded by the strut's half-leading. */}
+
{profileMenu}
+ {helpMenu}
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
new file mode 100644
index 00000000000..7040e44e82d
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts
@@ -0,0 +1 @@
+export { OrganizationHeader } from './organization-header'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
new file mode 100644
index 00000000000..6e8fe4ca13b
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx
@@ -0,0 +1,98 @@
+'use client'
+
+import {
+ ChipChevronDown,
+ chipContentLabelClass,
+ chipVariants,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ OverflowText,
+} from '@sim/emcn'
+import { PanelLeft } from '@sim/emcn/icons'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface'
+import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import { SIDEBAR_WIDTH } from '@/stores/constants'
+
+function getOrganizationInitial(name: string): string {
+ return (name.trim()[0] || 'O').toUpperCase()
+}
+
+interface OrganizationHeaderProps {
+ organization: OrganizationSurfaceOrganization
+ isCollapsed: boolean
+ /** Expands the rail; the collapsed header is itself the expand control. */
+ onExpandSidebar: () => void
+}
+
+/**
+ * The top-left organization chip. Expanded, it names the organization and opens
+ * the organization menu; collapsed, it becomes the rail's expand control, swapping
+ * the mark for a panel glyph on hover exactly as the workspace header does. The
+ * mark is the organization's uploaded logo or its initial on the neutral tile.
+ */
+export function OrganizationHeader({
+ organization,
+ isCollapsed,
+ onExpandSidebar,
+}: OrganizationHeaderProps) {
+ if (isCollapsed) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {/* Sized like the workspace switcher so the two menus open to the same footprint. */}
+ e.preventDefault()}
+ />
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts
new file mode 100644
index 00000000000..fe0024ab47c
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts
@@ -0,0 +1 @@
+export { WorkspacesRailFlyout } from './workspaces-rail-flyout'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx
new file mode 100644
index 00000000000..b6729ecbf07
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const workspacesState = vi.hoisted(() => ({
+ workspaces: [] as { id: string; name: string }[],
+ isLoading: false,
+}))
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({
+ useOrganizationWorkspaces: () => workspacesState,
+}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu',
+ () => ({
+ CollapsedResourceFlyout: ({
+ entries,
+ isLoading,
+ emptyLabel,
+ }: {
+ entries: { id: string; name: string; href: string }[]
+ isLoading: boolean
+ emptyLabel: string
+ }) =>
+ isLoading ? (
+ Loading...
+ ) : entries.length === 0 ? (
+ {emptyLabel}
+ ) : (
+ entries.map((entry) => (
+
+ {entry.name}
+
+ ))
+ ),
+ })
+)
+
+import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout'
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ workspacesState.workspaces = []
+ workspacesState.isLoading = false
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+})
+
+async function render() {
+ await act(async () => {
+ root.render()
+ })
+}
+
+describe('WorkspacesRailFlyout', () => {
+ it('lists every workspace as a link into it', async () => {
+ workspacesState.workspaces = [
+ { id: 'ws-1', name: 'Design' },
+ { id: 'ws-2', name: 'Ops' },
+ ]
+ await render()
+
+ const links = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href'))
+ expect(links).toEqual(['/workspace/ws-1', '/workspace/ws-2'])
+ expect(container.textContent).toContain('Design')
+ })
+
+ it('shows the empty label when the organization has no workspaces', async () => {
+ await render()
+ expect(container.textContent).toContain('No workspaces yet')
+ })
+
+ it('shows the loading row while the list resolves', async () => {
+ workspacesState.isLoading = true
+ await render()
+ expect(container.textContent).toContain('Loading...')
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx
new file mode 100644
index 00000000000..6f57cee3bc9
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { useMemo } from 'react'
+import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders'
+import { CollapsedResourceFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu'
+
+interface WorkspacesRailFlyoutProps {
+ organizationId: string
+}
+
+/**
+ * Rail flyout body for the Workspaces tab: a jump list of the organization's
+ * workspaces, one row each, the way the workspace sidebar's Tables and Files tabs
+ * list theirs. Mounts only while the rail menu is open, so the workspace query
+ * runs only when someone hovers the chip.
+ */
+export function WorkspacesRailFlyout({ organizationId }: WorkspacesRailFlyoutProps) {
+ const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId)
+
+ const entries = useMemo(
+ (): FlyoutEntry[] =>
+ workspaces.map((workspace) => ({
+ kind: 'item',
+ id: workspace.id,
+ name: workspace.name,
+ pinned: false,
+ href: `/workspace/${workspace.id}`,
+ })),
+ [workspaces]
+ )
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
new file mode 100644
index 00000000000..c96914ad41e
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts
@@ -0,0 +1,4 @@
+export { useCollapsedTooltips } from './use-collapsed-tooltips'
+export type { OrganizationChat } from './use-organization-chats'
+export { useOrganizationChats } from './use-organization-chats'
+export { useOrganizationWorkspaces } from './use-organization-workspaces'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts
new file mode 100644
index 00000000000..ec5451071ff
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react'
+
+/** How long the rail takes to settle after collapsing before row tooltips arm. */
+const COLLAPSED_TOOLTIP_DELAY_MS = 200
+
+/**
+ * Whether collapsed-rail tooltips should render. Arming is delayed past the rail's
+ * width animation so a tooltip never flashes beside a label that is still fading
+ * out; disarming is immediate so the expanded rail never shows one.
+ */
+export function useCollapsedTooltips(isCollapsed: boolean): boolean {
+ const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
+
+ useEffect(() => {
+ if (isCollapsed) {
+ const timer = setTimeout(() => setShowCollapsedTooltips(true), COLLAPSED_TOOLTIP_DELAY_MS)
+ return () => clearTimeout(timer)
+ }
+ setShowCollapsedTooltips(false)
+ }, [isCollapsed])
+
+ return showCollapsedTooltips
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
new file mode 100644
index 00000000000..52e183ce104
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts
@@ -0,0 +1,21 @@
+export interface OrganizationChat {
+ id: string
+ name: string
+ href: string
+ /** A run is in progress. */
+ isActive?: boolean
+ /** Has a reply the viewer has not opened. */
+ isUnread?: boolean
+ isPinned?: boolean
+}
+
+/** Stable identity for the empty list, so the section's memos don't churn. */
+const EMPTY_CHATS: OrganizationChat[] = []
+
+/**
+ * Chats listed in the organization sidebar. The organization surface has no chat
+ * source of its own, so the list is empty and never loading.
+ */
+export function useOrganizationChats(_organizationId: string) {
+ return { chats: EMPTY_CHATS, isLoading: false }
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
new file mode 100644
index 00000000000..ca8625ab1c5
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts
@@ -0,0 +1,21 @@
+import { useMemo } from 'react'
+import { useWorkspacesQuery, type Workspace } from '@/hooks/queries/workspace'
+
+/** Stable identity while the list loads, so the section's memos don't churn. */
+const EMPTY_WORKSPACES: Workspace[] = []
+
+/**
+ * The organization's workspaces the viewer belongs to, for the sidebar's
+ * Workspaces section. Read from the viewer's workspace list — the same query the
+ * workspace switcher uses — narrowed to those the organization owns.
+ */
+export function useOrganizationWorkspaces(organizationId: string) {
+ const { data = EMPTY_WORKSPACES, isLoading } = useWorkspacesQuery()
+
+ const workspaces = useMemo(
+ () => data.filter((workspace) => workspace.organizationId === organizationId),
+ [data, organizationId]
+ )
+
+ return { workspaces, isLoading }
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
new file mode 100644
index 00000000000..9963f275118
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts
@@ -0,0 +1 @@
+export { OrganizationSidebar } from './organization-sidebar'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
new file mode 100644
index 00000000000..4db8c4b4ebb
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts
@@ -0,0 +1,35 @@
+import { Home, Integration, Slash, Workspaces } from '@sim/emcn/icons'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+
+type OrganizationNavRoute = 'home' | 'integrations' | 'skills' | 'workspaces'
+
+interface OrganizationNavEntry {
+ id: string
+ label: string
+ icon: SidebarNavItemData['icon']
+ route: OrganizationNavRoute
+}
+
+/**
+ * The pinned block at the top of the organization sidebar, in display order.
+ * Hrefs are resolved per organization by {@link buildOrganizationNavItems}.
+ */
+
+/** The nav item whose collapsed rail chip also opens a flyout of the organization's workspaces. */
+export const WORKSPACES_NAV_ID = 'workspaces'
+
+const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [
+ { id: 'home', label: 'Home', icon: Home, route: 'home' },
+ { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
+ { id: 'skills', label: 'Skills', icon: Slash, route: 'skills' },
+ { id: 'workspaces', label: 'Workspaces', icon: Workspaces, route: 'workspaces' },
+]
+
+export function buildOrganizationNavItems(organizationId: string): SidebarNavItemData[] {
+ const routes = organizationRoutes(organizationId)
+ return ORGANIZATION_NAV_ENTRIES.map(({ route, ...entry }) => ({
+ ...entry,
+ href: routes[route],
+ }))
+}
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
new file mode 100644
index 00000000000..d16301de7bb
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx
@@ -0,0 +1,358 @@
+'use client'
+
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
+import { PanelLeft, Search } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { usePathname } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import { isMacPlatform } from '@/lib/core/utils/platform'
+import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
+import { captureEvent } from '@/lib/posthog/client'
+import {
+ ChatsSection,
+ OrganizationFooter,
+ OrganizationHeader,
+ WorkspacesRailFlyout,
+} from '@/app/o/[organizationId]/components/organization-sidebar/components'
+import {
+ useCollapsedTooltips,
+ useOrganizationChats,
+} from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import {
+ buildOrganizationNavItems,
+ WORKSPACES_NAV_ID,
+} from '@/app/o/[organizationId]/components/organization-sidebar/navigation'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
+import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
+import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
+import {
+ CollapsedSidebarMenu,
+ isNavItemActive,
+ NavItemContextMenu,
+ SidebarNavChip,
+ SidebarTooltip,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+ SIDEBAR_ITEM_GAP_CLASS,
+ SIDEBAR_SECTION_GAP_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+import {
+ useHoverMenu,
+ useSidebarResize,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
+import { useContextMenu } from '@/hooks/use-context-menu'
+import { useSidebarStore } from '@/stores/sidebar/store'
+
+const logger = createLogger('OrganizationSidebar')
+
+/**
+ * Opts a control out of the desktop shell's window-drag region. The header row is
+ * draggable chrome, so anything clickable inside it has to say so or the click is
+ * swallowed by the drag handler.
+ */
+const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
+
+/**
+ * The organization surface's rail: the same chrome as the workspace sidebar —
+ * header row, pinned nav block, a divided scroll region of sections, and the
+ * pinned footer — hosted by the same `WorkspaceChrome`, so collapse, resize, and
+ * the desktop hover-peek all behave identically. Collapse and peek state come from
+ * the chrome through {@link useSidebarChrome}.
+ */
+export const OrganizationSidebar = memo(function OrganizationSidebar() {
+ const { isCollapsed: railCollapsed, isPeeking } = useSidebarChrome()
+ /** The peek card always renders the expanded layout, whatever the rail's state. */
+ const isCollapsed = railCollapsed && !isPeeking
+
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+
+ const pathname = usePathname()
+ const posthog = usePostHog()
+ const { organization } = useOrganizationContext()
+ const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
+ const { handlePointerDown } = useSidebarResize()
+ const showCollapsedTooltips = useCollapsedTooltips(isCollapsed)
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
+ const { chats, isLoading: chatsLoading } = useOrganizationChats(organization.id)
+ const workspacesHover = useHoverMenu()
+
+ const isMac = isMacPlatform()
+ const navItems = useMemo(() => buildOrganizationNavItems(organization.id), [organization.id])
+
+ /**
+ * One menu serves every href-bearing row (nav items, workspaces, chats): the
+ * actions — open in a new tab, copy the link — only need the destination.
+ */
+ const [menuHref, setMenuHref] = useState(null)
+ const {
+ isOpen: isHrefMenuOpen,
+ position: hrefMenuPosition,
+ menuRef: hrefMenuRef,
+ handleContextMenu: openHrefMenu,
+ closeMenu: closeHrefMenu,
+ } = useContextMenu()
+
+ const handleHrefContextMenu = useCallback(
+ (e: React.MouseEvent, href: string) => {
+ setMenuHref(href)
+ openHrefMenu(e)
+ },
+ [openHrefMenu]
+ )
+
+ /** Anchors the menu to the row's options button rather than the pointer. */
+ const handleChatMoreClick = useCallback(
+ (e: React.MouseEvent, href: string) => {
+ if (isHrefMenuOpen) {
+ closeHrefMenu()
+ return
+ }
+ const rect = e.currentTarget.getBoundingClientRect()
+ setMenuHref(href)
+ openHrefMenu({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ clientX: rect.right,
+ clientY: rect.top,
+ } as React.MouseEvent)
+ },
+ [isHrefMenuOpen, closeHrefMenu, openHrefMenu]
+ )
+
+ const handleHrefMenuClose = useCallback(() => {
+ closeHrefMenu()
+ setMenuHref(null)
+ }, [closeHrefMenu])
+
+ const handleOpenInNewTab = useCallback(() => {
+ if (menuHref) window.open(menuHref, '_blank', 'noopener,noreferrer')
+ }, [menuHref])
+
+ const handleCopyLink = useCallback(async () => {
+ if (!menuHref) return
+ try {
+ await navigator.clipboard.writeText(`${window.location.origin}${menuHref}`)
+ } catch (error) {
+ logger.error('Failed to copy link to clipboard', { error })
+ }
+ }, [menuHref])
+
+ useEffect(() => {
+ if (!isHrefMenuOpen) setMenuHref(null)
+ }, [isHrefMenuOpen])
+
+ const handleOpenDocs = () => {
+ window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
+ captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
+ }
+
+ const handleOpenSlackCommunity = () => {
+ window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer')
+ captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' })
+ }
+
+ const handleEdgeKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) {
+ e.preventDefault()
+ toggleCollapsed()
+ }
+ },
+ [isCollapsed, toggleCollapsed]
+ )
+
+ useRegisterGlobalCommands(() =>
+ createCommands([
+ {
+ id: 'toggle-sidebar',
+ handler: () => {
+ toggleCollapsed()
+ },
+ },
+ ])
+ )
+
+ return (
+
+
+
+ {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that
+ out-specifies the `[data-peek]` rule, stranding the card at a stale width. */}
+ {!isPeeking && (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/not-found.tsx b/apps/sim/app/o/[organizationId]/not-found.tsx
new file mode 100644
index 00000000000..fe33e4d7c62
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/not-found.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import { Button, buttonVariants } from '@sim/emcn'
+import { ArrowLeft, Compass, Home } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { useParams, useRouter } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { ErrorShell } from '@/app/workspace/[workspaceId]/components/error/error'
+
+export default function OrganizationNotFound() {
+ const router = useRouter()
+ const { organizationId } = useParams<{ organizationId?: string }>()
+ const homeHref = organizationId ? organizationRoutes(organizationId).home : '/o'
+
+ return (
+ }
+ >
+
+
+
+ Return home
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/page.tsx b/apps/sim/app/o/[organizationId]/page.tsx
new file mode 100644
index 00000000000..ad93fa4fcc2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation'
+import { organizationRoutes } from '@/lib/navigation/paths'
+
+export default async function OrganizationPage({
+ params,
+}: {
+ params: Promise<{ organizationId: string }>
+}) {
+ const { organizationId } = await params
+ redirect(organizationRoutes(organizationId).home)
+}
diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
new file mode 100644
index 00000000000..57c300a7381
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
@@ -0,0 +1,32 @@
+'use client'
+
+import { createContext, type ReactNode, useContext } from 'react'
+import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
+
+const OrganizationContextValue = createContext(null)
+
+interface OrganizationProviderProps {
+ children: ReactNode
+ context: OrganizationSurfaceContext
+}
+
+/**
+ * Provides the route-resolved organization and the viewer's standing in it to the
+ * organization surface. The layout resolves both on the server, so the first paint
+ * already knows the organization's name and logo.
+ */
+export function OrganizationProvider({ children, context }: OrganizationProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function useOrganizationContext(): OrganizationSurfaceContext {
+ const context = useContext(OrganizationContextValue)
+ if (!context) {
+ throw new Error('useOrganizationContext must be used within OrganizationProvider')
+ }
+ return context
+}
diff --git a/apps/sim/app/o/[organizationId]/skills/page.tsx b/apps/sim/app/o/[organizationId]/skills/page.tsx
new file mode 100644
index 00000000000..e5941b2a62f
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/skills/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Skills',
+}
+
+export default function OrganizationSkillsPage() {
+ return null
+}
diff --git a/apps/sim/app/o/[organizationId]/workspaces/page.tsx b/apps/sim/app/o/[organizationId]/workspaces/page.tsx
new file mode 100644
index 00000000000..1844bb150b4
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/workspaces/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Workspaces',
+}
+
+export default function OrganizationWorkspacesPage() {
+ return null
+}
diff --git a/apps/sim/app/o/page.tsx b/apps/sim/app/o/page.tsx
new file mode 100644
index 00000000000..b0f5c8f2751
--- /dev/null
+++ b/apps/sim/app/o/page.tsx
@@ -0,0 +1,16 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
+
+/**
+ * Bare `/o` has no organization to show, so it resolves exactly like the app entry:
+ * the viewer's organization home, or their workspaces when they belong to none.
+ */
+export default async function OrganizationIndexPage() {
+ const session = await getSession()
+ if (!session?.user) {
+ redirect('/login')
+ }
+
+ redirect(await resolveAppEntryPath(session))
+}
diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
index 8b28bf1f473..bd72e37127b 100644
--- a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
@@ -128,7 +128,7 @@ describe('ChatCompleteHandoff', () => {
vi.advanceTimersByTime(400)
})
- expect(calls).toEqual(['/workspace'])
+ expect(calls).toEqual(['/home'])
act(() => root.unmount())
})
})
diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
index 258bf17d1e8..7965bf186d4 100644
--- a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
@@ -6,6 +6,7 @@ import {
OAUTH_CHAT_RETURN_TO_PARAM,
setOAuthChatAttemptStatus,
} from '@/lib/credentials/oauth-chat-attempt'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const CLOSE_FALLBACK_DELAY_MS = 400
@@ -57,7 +58,7 @@ export function ChatCompleteHandoff() {
window.close()
const timer = window.setTimeout(() => {
- window.location.replace(returnTo ?? '/workspace')
+ window.location.replace(returnTo ?? APP_ENTRY_PATH)
}, CLOSE_FALLBACK_DELAY_MS)
return () => window.clearTimeout(timer)
}, [])
diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx
index 72606f82511..289d49be15f 100644
--- a/apps/sim/app/oauth/credential-connected/page.tsx
+++ b/apps/sim/app/oauth/credential-connected/page.tsx
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { LogoShell } from '@/app/(landing)/components'
export const metadata: Metadata = {
@@ -30,7 +31,7 @@ export default async function CredentialConnectedPage({
? 'The credential is ready to use. You can close this tab and return to the app that started the connection.'
: 'The credential could not be connected. Return to the app that started the connection and try again.'}
-
+
Open Sim
diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
index f88977be585..24a22ed7d60 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Banner } from '@sim/emcn'
import { useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
import { clearUserData } from '@/stores'
@@ -39,7 +40,7 @@ export function ImpersonationBanner() {
onSuccess: async () => {
setIsRedirecting(true)
await clearUserData({ preserveRecentImpersonations: true })
- window.location.assign('/workspace')
+ window.location.assign(APP_ENTRY_PATH)
},
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
index ef011ad5ad6..6184759bc63 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
@@ -1,5 +1,6 @@
import { ChipLink } from '@sim/emcn'
import { CircleAlert } from '@sim/emcn/icons'
+import { WORKSPACES_PATH } from '@/lib/navigation/paths'
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
export function WorkspaceAccessDenied() {
@@ -17,7 +18,7 @@ export function WorkspaceAccessDenied() {
choose another workspace.
-
+
View your workspaces
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
index f568d2dec2d..fdd2f9bf069 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
@@ -1 +1,3 @@
+export type { SidebarChromeState } from './sidebar-chrome-context'
+export { SidebarChromeProvider, useSidebarChrome } from './sidebar-chrome-context'
export { WorkspaceChrome } from './workspace-chrome'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx
new file mode 100644
index 00000000000..cdb39e7de5d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import { createContext, type ReactNode, useContext, useMemo } from 'react'
+
+export interface SidebarChromeState {
+ /**
+ * Authoritative collapse state, derived once in `WorkspaceChrome` from the
+ * `sidebar_collapsed` cookie (server prop → store after hydration) so the rail's
+ * structure, labels, and width all read a single source.
+ */
+ isCollapsed: boolean
+ /**
+ * True while the sidebar is rendered as the desktop hover-peek card. The card shows
+ * the expanded layout even though the rail is collapsed, so a sidebar treats this
+ * as overriding {@link SidebarChromeState.isCollapsed} — and separately suppresses
+ * the chrome the card already provides (the title-bar lane, drag-resize).
+ */
+ isPeeking: boolean
+}
+
+const SidebarChromeContext = createContext(null)
+
+interface SidebarChromeProviderProps extends SidebarChromeState {
+ children: ReactNode
+}
+
+/**
+ * Hands the chrome's collapse and peek state to whichever sidebar it hosts. The
+ * chrome owns that state; the sidebar is passed in as an element, so it cannot take
+ * the values as props from a server layout — it reads them here instead.
+ */
+export function SidebarChromeProvider({
+ isCollapsed,
+ isPeeking,
+ children,
+}: SidebarChromeProviderProps) {
+ const value = useMemo(() => ({ isCollapsed, isPeeking }), [isCollapsed, isPeeking])
+ return {children}
+}
+
+export function useSidebarChrome(): SidebarChromeState {
+ const context = useContext(SidebarChromeContext)
+ if (!context) {
+ throw new Error('useSidebarChrome must be used within WorkspaceChrome')
+ }
+ return context
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
index f782b93d2f5..fd0e5aac7f4 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
@@ -1,23 +1,20 @@
'use client'
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { type ReactNode, useEffect, useLayoutEffect, useState } from 'react'
import { cn } from '@sim/emcn'
import { ArrowLeft, ArrowRight, PanelLeft } from '@sim/emcn/icons'
import { usePathname } from 'next/navigation'
import { getDesktopBridge } from '@/lib/desktop'
import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar'
+import { SidebarChromeProvider } from '@/app/workspace/[workspaceId]/components/workspace-chrome/sidebar-chrome-context'
import { useSidebarPeek } from '@/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek'
-import { Sidebar, SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
-/** Slide timing for the fullscreen sidebar collapse and content shift. */
-const SLIDE_TRANSITION =
- '[transition-duration:175ms] [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none'
-
/**
* The peek card's floating chrome.
*
@@ -63,25 +60,28 @@ const PEEK_CARD_EXIT = cn(
'pointer-events-none animate-out fade-out-0 zoom-out-95 fill-mode-forwards duration-150 ease-out motion-reduce:animate-none'
)
-/** The docked rail: in flow, width-animated by the collapse toggle. */
-const SIDEBAR_SHELL_IN_FLOW = cn('transition-[width]', SLIDE_TRANSITION)
-
/**
- * The content pane's own chrome, dropped when the pane sits flush to the window.
- *
- * Collapsing the sidebar in the desktop shell takes the surrounding padding to `0`,
- * which puts the pane hard against the window edge — and its border and radius then
- * draw a hairline outline with rounded corners inset from the square window frame.
+ * The divider between the rail and the content pane, dropped when there is no rail
+ * beside it: collapsed to nothing in the desktop shell, where the pane sits hard
+ * against the window edge. A fullscreen route drops it through React state instead,
+ * since that is a navigation rather than a pre-paint attribute.
*
* Keyed off the ancestor attributes rather than React state on purpose: the title-bar
- * attribute is written pre-paint, so a state-driven rule would flash the border on
+ * attribute is written pre-paint, so a state-driven rule would flash the line on
* first paint before hydration settles.
*/
-const CONTENT_PANE_FLUSH =
- '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:rounded-none [[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:border-0'
+const CONTENT_PANE_DIVIDER =
+ 'border-l border-[var(--border)] [[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:border-l-0'
interface WorkspaceChromeProps {
- children: React.ReactNode
+ children: ReactNode
+ /**
+ * The rail this chrome hosts. Rendered once inside the shell and never re-mounted
+ * across collapse, peek, or fullscreen; it reads collapse and peek state through
+ * {@link useSidebarChrome}. The workspace passes its own `Sidebar`; the organization
+ * surface passes `OrganizationSidebar`.
+ */
+ sidebar: ReactNode
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
initialSidebarCollapsed?: boolean
}
@@ -154,15 +154,15 @@ function isFullscreenPath(pathname: string | null): boolean {
}
/**
- * Renders the workspace chrome as a single persistent tree. The sidebar is
+ * Renders the app chrome as a single persistent tree — the workspace layout and the
+ * organization layout both mount it, each with its own sidebar. The sidebar is
* always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to
- * zero width while the inner shell slides off the left edge, revealing the route
- * content. Because this component lives in the workspace layout it persists
- * across navigations, so the pathname-driven class toggle animates smoothly.
+ * zero width, revealing the route content. Because this component lives in the
+ * layout it persists across navigations, so the rail never re-mounts.
*
- * Leaving a fullscreen route is instant: App Router swaps `children` to the
- * origin page and the fullscreen page is simply unmounted, while the sidebar
- * slides back in. There is no exit fade — the new page just loads in place.
+ * Nothing here animates: collapse, expand, and the fullscreen swap all apply in
+ * one frame. The rail and the pane meet on a single hairline divider with no
+ * gutter, radius, or shift between states.
*
* Because the chrome observes every pathname transition, it records the page a
* fullscreen route was launched from into {@link useFullscreenOriginStore}. The
@@ -170,9 +170,6 @@ function isFullscreenPath(pathname: string | null): boolean {
* trigger that merely pushes a fullscreen route gets correct return-to-origin
* without per-call-site wiring.
*
- * On a direct load of a fullscreen route the wrapper mounts already collapsed,
- * so no slide plays (CSS transitions don't run on mount).
- *
* On the macOS desktop shell, where collapsing hides the rail entirely, the same
* wrapper doubles as the hover-peek card: hovering the title-bar sidebar toggle
* takes it out of flow, floats it over the content inset from the window edge, and
@@ -181,10 +178,9 @@ function isFullscreenPath(pathname: string | null): boolean {
*/
export function WorkspaceChrome({
children,
+ sidebar,
initialSidebarCollapsed = false,
}: WorkspaceChromeProps) {
- const rafRef = useRef(0)
-
const pathname = usePathname()
const isFullscreen = isFullscreenPath(pathname)
@@ -228,29 +224,6 @@ export function WorkspaceChrome({
const { isPeekActive, isPeekOpen, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } =
useSidebarPeek(peekEnabled, isSearchModalOpen)
- /**
- * Suppresses sidebar transitions across the initial hydration window. The
- * pre-paint script already set the correct `--sidebar-width`, but the store
- * rehydration below re-applies it a tick later; without this guard that
- * re-apply animates the rail, reading as a collapse -> expand flash on a
- * fresh load. Applied before the rehydrate effect so the class is in place
- * ahead of the width mutation, then lifted after the first paint so
- * user-driven collapse toggles and the fullscreen slide still animate.
- */
- useLayoutEffect(() => {
- const root = document.documentElement
- root.classList.add('sidebar-booting')
- const raf1 = requestAnimationFrame(() => {
- const raf2 = requestAnimationFrame(() => root.classList.remove('sidebar-booting'))
- rafRef.current = raf2
- })
- rafRef.current = raf1
- return () => {
- cancelAnimationFrame(rafRef.current)
- root.classList.remove('sidebar-booting')
- }
- }, [])
-
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
useLayoutEffect(() => {
void useSidebarStore.persist.rehydrate()
@@ -362,7 +335,9 @@ export function WorkspaceChrome({
? isPeekOpen
? PEEK_CARD_ENTER
: PEEK_CARD_EXIT
- : cn(isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]', SIDEBAR_SHELL_IN_FLOW)
+ : isFullscreen
+ ? 'w-0'
+ : 'w-[var(--sidebar-width)]'
)}
data-collapsed={isCollapsed || undefined}
data-peek={isPeekActive || undefined}
@@ -370,23 +345,14 @@ export function WorkspaceChrome({
aria-hidden={isFullscreen || (isPeekActive && !isPeekOpen) || undefined}
suppressHydrationWarning
>
-
-
+
+
+ {sidebar}
+
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
index 9829532420e..5ab1b986952 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
@@ -21,6 +21,7 @@ import {
setOAuthChatAttemptStatus,
} from '@/lib/credentials/oauth-chat-attempt'
import { getDesktopBridge } from '@/lib/desktop'
+import { isAppSurfacePath } from '@/lib/navigation/paths'
import type { OAuthProvider } from '@/lib/oauth/types'
import { parseProvider, providerIdsForService } from '@/lib/oauth/utils'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
@@ -38,12 +39,21 @@ const OAUTH_POPUP_POLL_INTERVAL_MS = 400
const OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS = 10 * 60 * 1000
/**
- * Same-origin pages an OAuth flow can die on without reaching the return leg —
* Better Auth sends pre-state failures (usually a denied consent) to its global
- * error page, and the custom-provider callbacks exit to the workspace root.
- * Neither publishes a verdict, so a popup sitting on one is finished.
+ * error page, which publishes no verdict.
+ */
+const OAUTH_ERROR_PATH = '/oauth-error'
+
+/**
+ * Same-origin pages an OAuth flow can die on without reaching the return leg —
+ * the Better Auth error page, or anywhere in the signed-in app, which is where the
+ * custom-provider callbacks exit to. The app entry forwards on the server to the
+ * organization or a workspace, so any app surface counts, not just the entry
+ * itself. None of them publishes a verdict, so a popup sitting on one is finished.
*/
-const OAUTH_POPUP_TERMINAL_PATHS = new Set(['/oauth-error', '/workspace'])
+function isOAuthPopupTerminalPath(pathname: string): boolean {
+ return pathname === OAUTH_ERROR_PATH || isAppSurfacePath(pathname)
+}
/**
* What the opener can actually prove about a popup it launched. `ended` needs
@@ -64,7 +74,7 @@ function observePopup(popup: { window: Window } | null): PopupObservation {
if (closed) return 'unobservable'
try {
const { origin, pathname } = popup.window.location
- if (origin === window.location.origin && OAUTH_POPUP_TERMINAL_PATHS.has(pathname)) {
+ if (origin === window.location.origin && isOAuthPopupTerminalPath(pathname)) {
return 'ended'
}
} catch {
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
index 26305f13a74..79f619de2cd 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx
@@ -65,6 +65,10 @@ vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({
WorkspaceChrome: ({ children }: { children: ReactNode }) => children,
}))
+vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
+ Sidebar: () => null,
+}))
+
vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({
WorkspaceAccessDenied: () =>
Workspace access denied
,
}))
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index 01d1c56062a..1e93ff58add 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -23,6 +23,7 @@ import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings
import { WorkspaceHostProvider } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
+import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
@@ -82,7 +83,10 @@ export default async function WorkspaceLayout({
-
+ }
+ initialSidebarCollapsed={initialSidebarCollapsed}
+ >
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index 6ca773ac144..85fa1441c6e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -20,6 +20,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { useQueryStates } from 'nuqs'
import type { MothershipEnvironment } from '@/lib/api/contracts'
import { useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal'
import {
adminParsers,
@@ -158,7 +159,7 @@ export function Admin() {
onSuccess: async () => {
recordImpersonation(email)
await clearUserData({ preserveRecentImpersonations: true })
- window.location.assign('/workspace')
+ window.location.assign(APP_ENTRY_PATH)
},
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
index 2d21ca6bed7..ebbf8e42b46 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
@@ -7,6 +7,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import {
@@ -224,7 +225,7 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
})
if (isSelfRemoval) {
- window.location.href = '/workspace'
+ window.location.href = APP_ENTRY_PATH
}
} catch (error) {
logger.error('Failed to remove member', error)
@@ -266,7 +267,7 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
setTransferDialogOpen(false)
if (result.left) {
- window.location.href = '/workspace'
+ window.location.href = APP_ENTRY_PATH
}
} catch (error) {
logger.error('Failed to transfer ownership', error)
@@ -282,7 +283,7 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr
{
context: 'organization',
organizationId,
- returnUrl: `${getBaseUrl()}/workspace`,
+ returnUrl: `${getBaseUrl()}${APP_ENTRY_PATH}`,
},
{
onSuccess: (data) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
index 32af4cc4d53..b12086f7a27 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx
@@ -467,7 +467,6 @@ export function ConnectionBlockSelector({ id, data }: NodeProps {
href: '/workspace/w1/tables/t2',
},
]}
- icon={Table}
emptyLabel='No tables yet'
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
index 8d6431cd43f..021f2a21e46 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
@@ -1,4 +1,4 @@
-import { type ComponentType, type MouseEvent as ReactMouseEvent, useState } from 'react'
+import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import {
Chip,
chipVariants,
@@ -15,7 +15,7 @@ import {
Loader,
OverflowText,
} from '@sim/emcn'
-import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
+import { MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
import Link from 'next/link'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders'
@@ -32,8 +32,6 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface CollapsedResourceFlyoutProps {
entries: FlyoutEntry[]
- /** Icon for the resource rows. Folders always carry the folder glyph. */
- icon: ComponentType<{ className?: string }>
/** Resource open on the current route, so its row reads as selected. */
currentItemId?: string
/**
@@ -49,11 +47,12 @@ interface CollapsedResourceFlyoutProps {
/**
* Rail flyout body for a foldered workspace resource (Tables, Files). Every row
* is a link — the flyout is a jump list, so folders open as submenus rather than
- * navigating, and an empty one has nowhere to go and is inert.
+ * navigating, and an empty one has nowhere to go and is inert. Rows carry no
+ * glyph: the rail chip the flyout hangs off already names the resource, so a
+ * repeated icon on every row is noise in a list that exists only to be scanned.
*/
export function CollapsedResourceFlyout({
entries,
- icon,
currentItemId,
isLoading = false,
emptyLabel,
@@ -69,7 +68,7 @@ export function CollapsedResourceFlyout({
if (entries.length === 0) {
return {emptyLabel}
}
- return
+ return
}
/**
@@ -85,9 +84,8 @@ function PinnedGlyph() {
function CollapsedFlyoutRows({
entries,
- icon: Icon,
currentItemId,
-}: Pick) {
+}: Pick) {
return (
<>
{entries.map((entry) => {
@@ -95,7 +93,6 @@ function CollapsedFlyoutRows({
return (
-
{entry.pinned && }
@@ -106,7 +103,6 @@ function CollapsedFlyoutRows({
if (entry.children.length === 0) {
return (
-
{entry.pinned && }
@@ -116,16 +112,11 @@ function CollapsedFlyoutRows({
return (
-
{entry.pinned && }
-
+
)
@@ -520,7 +511,6 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
if (!hasChildren) {
return (
-
)
@@ -529,7 +519,6 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
return (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
index 6ad98b4755c..e735e674e89 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
@@ -13,8 +13,9 @@ export { SearchModal } from './search-modal'
export { SettingsSidebar } from './settings-sidebar'
export { SidebarFooter } from './sidebar-footer'
export type { SidebarNavItemData } from './sidebar-nav-chip'
-export { SidebarNavChip } from './sidebar-nav-chip'
+export { isNavItemActive, SidebarNavChip } from './sidebar-nav-chip'
export { SidebarSection } from './sidebar-section'
+export { SidebarTooltip } from './sidebar-tooltip'
export { StatusNotice } from './status-notice'
export { WorkflowList } from './workflow-list'
export { WorkspaceHeader } from './workspace-header'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
index 7cddf15ef18..19daa684447 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx
@@ -65,7 +65,6 @@ export function TablesRailFlyout({ workspaceId }: { workspaceId: string }) {
return (
{
vi.unstubAllGlobals()
})
- it('fades the palette with the short pixel-anchored mask and the shared search surface', () => {
+ it('keeps the list unfogged at rest, insets the fade under the search field, and shares the search surface', () => {
act(() => {
root.render(
-
+
)
@@ -59,7 +59,9 @@ describe('CommandFadedList', () => {
const list = container.querySelector('[cmdk-list]')
const input = container.querySelector('[cmdk-input]')
const search = container.querySelector('[cmdk-input]')?.parentElement
- expect(list?.className).toContain('transparent_36px,black_58px,black_calc(100%_-_13px)')
+ expect(list?.className).toContain('[--scroll-fade-inset:3rem]')
+ expect(list?.hasAttribute('data-scroll-fade-top')).toBe(false)
+ expect(list?.hasAttribute('data-scroll-fade-bottom')).toBe(false)
expect(list?.className).not.toContain('scrollbar-track')
expect(input?.className).toContain('-ml-1')
expect(input?.className).toContain('indent-1')
@@ -71,7 +73,7 @@ describe('CommandFadedList', () => {
root.render(
-
+ FirstSecond
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
index e65664f1b83..f13bb242a94 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx
@@ -5,8 +5,10 @@ import {
forwardRef,
type KeyboardEvent,
type ReactNode,
+ useCallback,
+ useRef,
} from 'react'
-import { cn } from '@sim/emcn'
+import { cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import { Search } from '@sim/emcn/icons'
import { Command } from 'cmdk'
@@ -20,10 +22,6 @@ interface CommandSearchProps extends Omit {
endAdornment?: ReactNode
}
-interface CommandFadedListProps extends CommandListProps {
- fade: 'canvas' | 'palette'
-}
-
/**
* The fog must repaint its host's exact background or it reads as a tinted
* band under the input: the canvas selector card fills with `--surface-2`,
@@ -37,24 +35,6 @@ const SEARCH_SURFACE_CLASSNAME = {
'bg-[linear-gradient(to_bottom,var(--bg)_0%,color-mix(in_srgb,var(--bg)_88%,transparent)_68%,transparent_100%)]',
} as const
-/**
- * The palette hides its scrollbar (`scrollbar-none` at the call site), so it
- * fades with one plain mask; its band is kept short — fully masked only under
- * the floating input (0–36px), legible by 58px, and a brief 13px exit — so
- * rows spend less time in the fog than on the canvas surface. The palette's
- * stops are anchored in pixels (the 448px max-height look frozen) because the
- * list shrinks to its content: percentage stops would move the fog on every
- * result-count change, a shimmer the dark selected first row makes obvious.
- * The canvas list fills a fixed-height card, so its percentage stops never
- * move.
- */
-const LIST_FADE_CLASSNAME = {
- canvas:
- '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]',
- palette:
- '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_36px,black_58px,black_calc(100%_-_13px),transparent_100%)]',
-} as const
-
/**
* Borderless search field layered over a fading command-result list.
*
@@ -102,17 +82,36 @@ export const CommandSearch = forwardRef(
CommandSearch.displayName = 'CommandSearch'
-/** Scrollable command list with soft edge fades tuned for each command surface. */
-export const CommandFadedList = forwardRef(
- function CommandFadedList({ className, fade, ...props }, ref) {
+/**
+ * Scrollable command list with the shared edge fade. The search field floats over
+ * the list's top 48px (`pt-12` keeps the first row clear of it), so the top band
+ * is inset by that height: while scrolled, rows are fully hidden under the field
+ * and fade in just beneath it. At rest neither edge fades, so the first group's
+ * heading and the last row are never fogged on a list that has not moved.
+ */
+export const CommandFadedList = forwardRef(
+ function CommandFadedList({ className, ...props }, ref) {
+ const listRef = useRef(null)
+ const edges = useScrollEdges(listRef)
+
+ const setRefs = useCallback(
+ (node: HTMLDivElement | null) => {
+ listRef.current = node
+ if (typeof ref === 'function') ref(node)
+ else if (ref) ref.current = node
+ },
+ [ref]
+ )
+
return (
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
index 4bf47d8dc4c..c0ce35be12d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx
@@ -5,7 +5,8 @@ import { memo } from 'react'
import { OverflowText } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
-import { HEX_COLOR_REGEX } from '@/lib/branding'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
+import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { BlockTile } from '@/blocks/block-tile'
@@ -247,7 +248,6 @@ export const MemoizedWorkspaceItem = memo(
name,
isCurrent,
logoUrl,
- color,
meta,
}: {
value: string
@@ -255,31 +255,10 @@ export const MemoizedWorkspaceItem = memo(
name: string
isCurrent?: boolean
logoUrl?: string | null
- color?: string
} & ResultMetaProps) {
- const backgroundColor = color && HEX_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)'
-
return (
- {logoUrl ? (
-
- ) : (
-
-
- {name.charAt(0).toUpperCase() || 'W'}
-
- )}
+
{isCurrent && (current)}
@@ -293,7 +272,6 @@ export const MemoizedWorkspaceItem = memo(
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.logoUrl === next.logoUrl &&
- prev.color === next.color &&
prev.meta === next.meta
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
index 75a8e76b7ba..ef133e937a4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx
@@ -53,7 +53,6 @@ const workspaceItems: WorkspaceItem[] = [
id: 'workspace-beta',
name: 'Beta Workspace',
href: '/workspace/workspace-beta/w',
- color: '#123456',
},
]
@@ -127,11 +126,10 @@ describe('SearchEntryGroup', () => {
})
const logo = container.querySelector('img[data-slot="workspace-icon"]')
- const fallback = container.querySelector('span[data-slot="workspace-icon"]')
+ const fallback = container.querySelector('div[data-slot="workspace-icon"]')
expect(logo?.src).toBe('https://cdn.example.com/acme.png')
expect(logo?.alt).toBe('')
expect(fallback?.textContent).toBe('B')
- expect(fallback?.querySelector('rect')?.getAttribute('fill')).toBe('#123456')
})
it('renders workspace icons in the default workspace section', () => {
@@ -151,6 +149,6 @@ describe('SearchEntryGroup', () => {
})
expect(container.querySelector('img[data-slot="workspace-icon"]')).not.toBeNull()
- expect(container.querySelector('span[data-slot="workspace-icon"]')?.textContent).toBe('B')
+ expect(container.querySelector('div[data-slot="workspace-icon"]')?.textContent).toBe('B')
})
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
index 5277dd70a84..89b130ca569 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx
@@ -243,7 +243,6 @@ function renderSearchEntry(
name={entry.item.name}
isCurrent={entry.item.isCurrent}
logoUrl={entry.item.logoUrl}
- color={entry.item.color}
/>
)
case 'pages':
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index 6dfc70e6bbc..5597803ef7b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -1351,7 +1351,6 @@ function SearchModalContent({
rows against an edge the user cannot see. */}
s.pendingLeave)
const showDiscardDialog = pendingLeave !== null
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
const [desktopSurfaces, setDesktopSurfaces] = useState>({
settings: false,
browser: false,
@@ -303,37 +309,19 @@ export function SettingsSidebar({
})
}, [])
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
-
- const updateScrollState = () => {
- setHasOverflowTop(container.scrollTop > 1)
- }
-
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) {
- observer.observe(scrollContentRef.current)
- }
-
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [isCollapsed])
-
return (
<>
{/* Back button */}
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
{sectionConfig
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
index 0684acb4c24..03dda46288f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx
@@ -42,9 +42,12 @@ vi.mock('@/hooks/use-workspace-invite-policy', () => ({
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
useWorkspaceHostContext: () => null,
}))
-vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({
- SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
-}))
+vi.mock(
+ '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip',
+ () => ({
+ SidebarTooltip: ({ children }: { children: React.ReactNode }) => children,
+ })
+)
vi.mock('@/components/icons', () => ({
SlackIcon: ({ className }: { className?: string }) => ,
}))
@@ -63,6 +66,7 @@ async function renderFooter(
root.render(
`/workspace/workspace-1/settings/${section}`}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
index 612a927149b..44b135b4c92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx
@@ -27,11 +27,11 @@ import { getDesktopUpdates } from '@/lib/desktop'
import { getUserColor } from '@/lib/workspaces/colors'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip'
import {
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
-import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useUserProfile } from '@/hooks/queries/user-profile'
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
import { useWorkspaceInvitePolicy } from '@/hooks/use-workspace-invite-policy'
@@ -88,6 +88,12 @@ function DesktopUpdateIcon({ className }: { className?: string }) {
interface SidebarFooterProps {
workspaceId: string
+ /**
+ * True while the scroll region above still hides rows beyond its bottom edge —
+ * the same test the divider under the pinned nav applies at the top. The bar's
+ * top rule is drawn only then, so a list that fits meets the footer with no line.
+ */
+ showDivider: boolean
isCollapsed: boolean
showCollapsedTooltips: boolean
getSettingsHref: (section: SettingsSection) => string
@@ -122,6 +128,7 @@ interface SidebarFooterProps {
*/
export function SidebarFooter({
workspaceId,
+ showDivider,
isCollapsed,
showCollapsedTooltips,
getSettingsHref,
@@ -346,7 +353,8 @@ export function SidebarFooter({
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
index 93f9e4dc7d0..83e1718ed5d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts
@@ -1,2 +1,2 @@
export type { SidebarNavItemData } from './sidebar-nav-chip'
-export { SidebarNavChip } from './sidebar-nav-chip'
+export { isNavItemActive, SidebarNavChip } from './sidebar-nav-chip'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
index f159cd0b6fe..d52b32106af 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx
@@ -14,6 +14,17 @@ export interface SidebarNavItemData {
additionalActivePaths?: string[]
}
+/**
+ * Whether `pathname` matches `item.href` or any of its `additionalActivePaths` at a
+ * segment boundary, so `/foo` never lights up for `/foo-bar`.
+ */
+export function isNavItemActive(item: SidebarNavItemData, pathname: string | null): boolean {
+ if (!pathname) return false
+ const matches = (p: string) => pathname === p || pathname.startsWith(`${p}/`)
+ if (item.href && matches(item.href)) return true
+ return item.additionalActivePaths?.some(matches) ?? false
+}
+
interface SidebarNavChipProps extends React.HTMLAttributes {
item: SidebarNavItemData
active: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
index 0516ff98ce7..bfa2112bb31 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section/sidebar-section.tsx
@@ -48,14 +48,8 @@ export function SidebarSection({
children,
}: SidebarSectionProps) {
const [expanded, setExpanded] = useState(true)
- /**
- * Collapse animations are enabled only after the first user toggle, so sections
- * render at full height on mount instead of replaying the open animation.
- */
- const [animationsEnabled, setAnimationsEnabled] = useState(false)
const handleToggle = () => {
- setAnimationsEnabled(true)
setExpanded((prev) => !prev)
}
@@ -97,8 +91,10 @@ export function SidebarSection({
{/* Carries the gutter the row gave up so the toggle can reach the rail's edge. */}
{action ?
{action}
: null}
+ {/* `animate-none!`: the disclosure opens and closes in one frame, like every
+ other change of the rail's shape. */}
-
+
{/* The header gap pads an inner wrapper rather than the animated element:
`collapsible-up`/`-down` interpolate height alone, so a margin here would
hold its full 6px for the whole close and then vanish on unmount, snapping
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts
new file mode 100644
index 00000000000..368cc3ad539
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/index.ts
@@ -0,0 +1 @@
+export { SidebarTooltip } from './sidebar-tooltip'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx
new file mode 100644
index 00000000000..2a9774ac101
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip/sidebar-tooltip.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { Tooltip } from '@sim/emcn'
+
+interface SidebarTooltipProps {
+ children: React.ReactElement
+ label: string
+ /** Renders the bare child when false, so a row can opt out without swapping element trees. */
+ enabled: boolean
+ side?: 'right' | 'bottom'
+ shortcut?: string
+}
+
+/**
+ * Tooltip for a sidebar control, shown while the rail is collapsed (the label is
+ * hidden) or on the header's icon-only chips. Returns `children` untouched when
+ * disabled so the wrapped element keeps its identity across the toggle.
+ */
+export function SidebarTooltip({
+ children,
+ label,
+ enabled,
+ side = 'right',
+ shortcut,
+}: SidebarTooltipProps) {
+ if (!enabled) return children
+ return (
+
+ {children}
+
+ {shortcut ? {label} :
{label}
}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
index 2f81336b20d..8c6c9e7476a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/status-notice/status-notice.tsx
@@ -47,5 +47,11 @@ export function StatusNotice({ preview = false }: StatusNoticeProps) {
return null
}
- return
+ /* The gutter lives here rather than on the sidebar's slot: a slot padded for a
+ notice that renders nothing would hold an empty band above the footer. */
+ return (
+
{
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
index e69b89f8653..a551bd68bad 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx
@@ -18,14 +18,19 @@ import {
Plus,
Send,
Skeleton,
+ scrollFadeAttributes,
+ scrollFadeClass,
Tooltip,
toast,
+ useScrollEdges,
} from '@sim/emcn'
import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
+import { IdentityTile } from '@/components/identity-tile/identity-tile'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -51,22 +56,15 @@ const logger = createLogger('WorkspaceHeader')
* list viewport to exactly this many rows — so the sixth workspace is the one that
* both fills the viewport and brings in search.
*
- * The viewport's `max-h-[190px]` is derived from it: 6 rows at `chipGeometryClass`'s
- * 30px plus the 2px `gap-0.5` between them (6 * 30 + 5 * 2). Tailwind arbitrary
- * values must be statically analyzable, so the arithmetic cannot live in the class —
- * change the two together.
+ * The viewport's `max-h-[200px]` is derived from it: 6 rows at `chipGeometryClass`'s
+ * 30px plus the 2px `gap-0.5` between them (6 * 30 + 5 * 2), plus the list's own
+ * `pt-1.5 pb-1` (6 + 4) — the gaps to the search field and the rule, carried as
+ * the scroll box's padding so rows scroll through them under the edge fade.
+ * Tailwind arbitrary values must be statically analyzable, so the arithmetic
+ * cannot live in the class — change them together.
*/
const WORKSPACE_SEARCH_THRESHOLD = 6
-/**
- * Derives the single-letter avatar initial for a workspace, ignoring the word
- * "workspace" in the name (e.g. "Acme Workspace" → "A").
- */
-function getWorkspaceInitial(name: string | undefined): string {
- const stripped = (name ?? '').replace(/workspace/gi, '').trim()
- return (stripped[0] || name?.[0] || 'W').toUpperCase()
-}
-
interface DisabledReasonTooltipProps {
reason: string | null
children: ReactElement
@@ -193,6 +191,13 @@ function WorkspaceHeaderImpl({
const renameInputRef = useRef(null)
const searchInputRef = useRef(null)
const workspaceListRef = useRef(null)
+ /**
+ * Held in state as well as the ref: the list lives in the menu's portal, which
+ * Radix mounts a commit after the menu opens, so the edge hook has to be handed
+ * the element itself to pick it up.
+ */
+ const [workspaceListElement, setWorkspaceListElement] = useState(null)
+ const listEdges = useScrollEdges(workspaceListElement)
const [workspaceSearch, setWorkspaceSearch] = useState('')
const [highlightedId, setHighlightedId] = useState(null)
@@ -459,28 +464,14 @@ function WorkspaceHeaderImpl({
className={cn(chipVariants({ fullWidth: true }), SIDEBAR_RAIL_CHIP_CLASS)}
>
- )
+
) : (
)}
@@ -617,12 +597,21 @@ function WorkspaceHeaderImpl({
if (target) onWorkspaceSwitch(target)
}
}}
- className='mb-1.5'
/>
)}
+ {/* The gaps to the search field above and the rule below are the list's
+ own padding, so at rest rows sit where they always did, and while
+ scrolling they run through the gap beneath the edge fade. */}
) : (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
index dc682c71374..3226942be37 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/constants.ts
@@ -20,12 +20,14 @@ export const SIDEBAR_SECTION_GAP_CLASS = 'mt-4'
export const SIDEBAR_ITEM_GAP_CLASS = 'gap-[1px]'
/**
- * Halves of {@link SIDEBAR_SECTION_GAP_CLASS} straddling the scroll region's
- * divider: the pinned block above carries the top half, the scroll region below
- * carries the bottom half. Split this way the divider sits centered in a gap that
- * reads as one section gap, so the first section header is spaced from the block
- * above it exactly like every other section boundary. Keep both in step with the
- * section gap.
+ * Halves of {@link SIDEBAR_SECTION_GAP_CLASS} straddling a divider: the block
+ * above carries the top half, the block below carries the bottom half. Split this
+ * way the divider sits centered in a gap that reads as one section gap, so the
+ * first section header is spaced from the pinned nav exactly like every other
+ * section boundary. The scroll region carries BOTH — the bottom half under the
+ * nav's divider and the top half above the footer's — as its own padding, so rows
+ * scroll through the gap beneath the edge fade rather than stopping short of the
+ * rule. Keep both in step with the section gap.
*/
export const SIDEBAR_DIVIDER_PAD_ABOVE_CLASS = 'pb-2'
export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
@@ -43,20 +45,9 @@ export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
* (rail midline 25.5 vs glyph column 24), which produced either a
* left-biased rail or a drift on toggle; keep the rail width and this chip
* width commensurate (rail = chip + 2 × gutter) if either ever changes.
- * Collapsing, the width tweens down to 32px on the 175ms curve the rail
- * closes on; expanding targets `auto` (not interpolable), so the chip snaps
- * to the still-narrow rail's width and stretch-tracks it open. The duration
- * is `!important` because the aside zeroes chip transition durations
- * (`[&_.group.cursor-pointer]:duration-0`) for instant hover fills — colors
- * are excluded from the property list here, so hover fills keep snapping.
+ * The width applies in one frame, in step with the rail itself.
*/
-export const SIDEBAR_RAIL_CHIP_CLASS = [
- 'transition-[width]',
- '![transition-duration:175ms]',
- '[transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)]',
- 'motion-reduce:transition-none!',
- 'group-data-[collapsed]/rail:w-[32px]',
-].join(' ')
+export const SIDEBAR_RAIL_CHIP_CLASS = 'group-data-[collapsed]/rail:w-[32px]'
/**
* Nested-selector variants for cmdk-based surfaces (e.g. the search modal).
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
index c7f7cdec75b..a444efe6a8b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { SIDEBAR_WIDTH } from '@/stores/constants'
-import { useSidebarStore } from '@/stores/sidebar/store'
+import { getMaxSidebarWidth, useSidebarStore } from '@/stores/sidebar/store'
/**
* Handles sidebar drag-resize with zero React renders during the drag.
@@ -8,10 +8,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* Architecture (confirmed industry best-practice for resize handles):
*
* pointerdown → capture the pointer on the handle (so move/up keep arriving
- * even when the cursor leaves the window or crosses an iframe),
- * add `is-resizing` class directly to the DOM (no React
- * round-trip, so the CSS width transition is suppressed from the
- * very first frame)
+ * even when the cursor leaves the window or crosses an iframe)
* pointermove → write --sidebar-width to `.sidebar-shell-outer` (the element
* that sizes the rail) inside a requestAnimationFrame callback.
* Scoping the variable to that subtree keeps the style recalc
@@ -23,9 +20,8 @@ import { useSidebarStore } from '@/stores/sidebar/store'
*
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
* interrupted gesture (release outside the window, alt-tab, context menu, the OS
- * stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
- * stuck — which would otherwise freeze the sidebar at a tiny width with the
- * collapse transition permanently disabled. A single-flight guard prevents
+ * stealing focus) can never leave the body cursor and selection lock stuck. A
+ * single-flight guard prevents
* stacking listeners across rapid presses, and unmounting mid-drag finalizes it
* the same way a release does — persisting the last width and dropping the
* scoped override — which matters because `.sidebar-shell-outer` lives in the
@@ -42,11 +38,8 @@ export function useSidebarResize() {
const handle = e.currentTarget
const pointerId = e.pointerId
- const sidebar = document.querySelector('.sidebar-container')
const shell = document.querySelector('.sidebar-shell-outer')
const target = shell ?? document.documentElement
- sidebar?.classList.add('is-resizing')
- document.documentElement.classList.add('sidebar-resizing')
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
handle.setPointerCapture?.(pointerId)
@@ -55,7 +48,7 @@ export function useSidebarResize() {
let lastWidth: number | null = null
const onPointerMove = (ev: PointerEvent) => {
- const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
+ const max = getMaxSidebarWidth(window.innerWidth)
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
lastWidth = clamped
if (rafId !== null) cancelAnimationFrame(rafId)
@@ -70,8 +63,6 @@ export function useSidebarResize() {
cancelAnimationFrame(rafId)
rafId = null
}
- sidebar?.classList.remove('is-resizing')
- document.documentElement.classList.remove('sidebar-resizing')
document.body.style.cursor = ''
document.body.style.userSelect = ''
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
index 0deca94ef97..8a821c0243a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts
@@ -164,7 +164,7 @@ export function useWorkspaceManagement({
const updateWorkspace = useCallback(
async (
workspaceId: string,
- updates: { name?: string; logoUrl?: string | null; color?: string }
+ updates: { name?: string; logoUrl?: string | null }
): Promise => {
try {
await updateWorkspaceMutation.mutateAsync({ workspaceId, ...updates })
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index 530e5a3dc6d..607ee1d0fa9 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -16,8 +16,11 @@ import {
Loader,
OverflowText,
Skeleton,
+ scrollFadeAttributes,
+ scrollFadeClass,
Tooltip,
Upload,
+ useScrollEdges,
} from '@sim/emcn'
import {
Database,
@@ -42,7 +45,9 @@ import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
+import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links'
import { captureEvent } from '@/lib/posthog/client'
+import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
@@ -57,6 +62,7 @@ import {
CollapsedWorkflowFlyoutItem,
FilesRailFlyout,
HelpModal,
+ isNavItemActive,
NavItemContextMenu,
SearchModal,
SettingsSidebar,
@@ -64,6 +70,7 @@ import {
SidebarNavChip,
type SidebarNavItemData,
SidebarSection,
+ SidebarTooltip,
StatusNotice,
TablesRailFlyout,
WorkflowList,
@@ -164,33 +171,6 @@ const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
minute: '2-digit',
})
-const SLACK_COMMUNITY_URL =
- 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA'
-
-export function SidebarTooltip({
- children,
- label,
- enabled,
- side = 'right',
- shortcut,
-}: {
- children: React.ReactElement
- label: string
- enabled: boolean
- side?: 'right' | 'bottom'
- shortcut?: string
-}) {
- if (!enabled) return children
- return (
-
- {children}
-
- {shortcut ? {label} :
{label}
}
-
-
- )
-}
-
/** Stands in for a chip row while a list loads, so it carries no margin either. */
function SidebarItemSkeleton() {
return (
@@ -326,17 +306,6 @@ const SidebarChatItem = memo(function SidebarChatItem({
)
})
-/**
- * Returns true when the current pathname matches `item.href` or any
- * `additionalActivePaths` at a segment boundary (avoids `/foo` matching `/foo-bar`).
- */
-function isNavItemActive(item: SidebarNavItemData, pathname: string | null): boolean {
- if (!pathname) return false
- const matches = (p: string) => pathname === p || pathname.startsWith(`${p}/`)
- if (item.href && matches(item.href)) return true
- return item.additionalActivePaths?.some(matches) ?? false
-}
-
const SidebarNavItem = memo(function SidebarNavItem({
item,
active,
@@ -385,30 +354,14 @@ const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]'
*
* This ensures server and client render identical HTML, preventing hydration errors.
*
+ * Collapse and peek state come from the hosting chrome through
+ * {@link useSidebarChrome}; the peek card always renders the expanded layout,
+ * whatever the rail's state.
+ *
* @returns Sidebar with workflows panel
*/
-interface SidebarProps {
- /**
- * Authoritative collapse state, derived once in {@link WorkspaceChrome} from the
- * `sidebar_collapsed` cookie (server prop → store after hydration) and passed in
- * so the rail's structure, labels, and width all read a single source.
- */
- isCollapsed: boolean
- /**
- * True while the sidebar is rendered as the desktop hover-peek card. The card shows
- * the expanded layout even though the rail is collapsed, so this overrides
- * {@link SidebarProps.isCollapsed} below — and separately suppresses the chrome the
- * card already provides: it sits below the traffic-light lane, and drag-resize would
- * fight the card's width.
- */
- isPeeking?: boolean
-}
-
-export const Sidebar = memo(function Sidebar({
- isCollapsed: isCollapsedProp,
- isPeeking = false,
-}: SidebarProps) {
- /** The peek card always renders the expanded layout, whatever the rail's state. */
+export const Sidebar = memo(function Sidebar() {
+ const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome()
const isCollapsed = isCollapsedProp && !isPeeking
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -772,7 +725,6 @@ export const Sidebar = memo(function Sidebar({
href: `/workspace/${workspace.id}/w`,
isCurrent: workspace.id === workspaceId,
logoUrl: workspace.logoUrl,
- color: workspace.color,
})),
[workspaces, workspaceId]
)
@@ -1028,29 +980,10 @@ export const Sidebar = memo(function Sidebar({
[workflowFlyoutRename, workflowsHover]
)
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
-
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
-
- const updateScrollState = () => {
- setHasOverflowTop(container.scrollTop > 1)
- }
-
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) {
- observer.observe(scrollContentRef.current)
- }
-
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [])
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false
@@ -1251,7 +1184,7 @@ export const Sidebar = memo(function Sidebar({
const handleOpenHelpFromMenu = () => setIsHelpModalOpen(true)
const handleOpenDocs = () => {
- window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
+ window.open(DOCS_URL, '_blank', 'noopener,noreferrer')
captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
}
@@ -1371,7 +1304,7 @@ export const Sidebar = memo(function Sidebar({
)}
) : (
<>
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
+
) : null}
getSettingsHref({ section })}
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx
index 55b14677dd3..3f00597bbed 100644
--- a/apps/sim/components/emails/billing/credit-purchase-email.tsx
+++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx
@@ -3,6 +3,7 @@ import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBaseUrl } from '@/lib/core/utils/urls'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditPurchaseEmailProps {
@@ -47,7 +48,7 @@ export function CreditPurchaseEmail({
Credits are applied automatically to your workflow executions.
- View Dashboard
+ View Dashboard
diff --git a/apps/sim/components/identity-tile/identity-tile.tsx b/apps/sim/components/identity-tile/identity-tile.tsx
new file mode 100644
index 00000000000..b9803d8e9c7
--- /dev/null
+++ b/apps/sim/components/identity-tile/identity-tile.tsx
@@ -0,0 +1,56 @@
+import { cn } from '@sim/emcn'
+
+interface IdentityTileProps {
+ /** Letter shown when there is no uploaded mark. */
+ initial: string
+ logoUrl?: string | null
+ /** Accessible name for an uploaded mark; empty when the name is already beside it. */
+ alt?: string
+ /** Layout-only extras (visibility, positioning). Never chrome. */
+ className?: string
+ /** `data-slot` hook for tests and styling. */
+ slot?: string
+}
+
+/**
+ * The 16px mark for a workspace or organization: its uploaded logo, or its
+ * initial on a neutral tile. There is no per-entity color — every tile is the
+ * same gray so an uploaded mark is the only thing that distinguishes one from
+ * another, exactly as an icon would.
+ *
+ * Chrome matches the chip family at tile scale: `rounded-sm` is the chip's
+ * `rounded-lg` scaled to a 16px box, and the letter sits at the smallest type
+ * token. The fill is `--surface-6`, one step past the chip hover and active
+ * fills, so the tile still reads as a tile on a hovered or selected row instead
+ * of dissolving into it. The letter is the icon gray in light mode and steps up
+ * to the secondary text gray in dark mode, where the icon gray sits too close
+ * to that fill. Plain `img`/`div`
+ * rather than the emcn `Avatar`, whose Radix root renders a `` — and globals
+ * fade every `span` in the collapsed rail to `opacity: 0`, which would blank the
+ * mark exactly where it is the only thing left to see.
+ */
+export function IdentityTile({ initial, logoUrl, alt = '', className, slot }: IdentityTileProps) {
+ if (logoUrl) {
+ return (
+
+ )
+ }
+ return (
+
+ {initial}
+
+ )
+}
diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx
index c210e9bbd1b..3554ddeb414 100644
--- a/apps/sim/components/settings/settings-sidebar.tsx
+++ b/apps/sim/components/settings/settings-sidebar.tsx
@@ -1,13 +1,16 @@
'use client'
-import { useEffect, useRef, useState } from 'react'
+import { useRef } from 'react'
import {
ChipConfirmModal,
chipIconSlotClass,
chipVariants,
cn,
OverflowText,
+ scrollFadeAttributes,
+ scrollFadeClass,
Tooltip,
+ useScrollEdges,
} from '@sim/emcn'
import { ChevronLeft } from '@sim/emcn/icons'
import { useRouter } from 'next/navigation'
@@ -18,18 +21,20 @@ import {
type StandaloneSettingsPlane,
} from '@/components/settings/navigation'
import { SettingsIntentLink } from '@/components/settings/settings-intent-link'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
/**
* The marketing landing page. `?home` is required: the proxy bounces a
- * signed-in user off `/` to `/workspace` unless the param is present.
+ * signed-in user off `/` to the app entry unless the param is present.
*/
const LANDING_HREF = '/?home'
-/** Where the Back chip goes on planes that don't show the wordmark. */
-const WORKSPACE_HREF = '/workspace'
-
interface SettingsNavigationGroup {
key: string
title: string
@@ -85,26 +90,23 @@ export function SettingsSidebar({
const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave)
const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave)
const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave)
- const [hasOverflowTop, setHasOverflowTop] = useState(false)
-
- useEffect(() => {
- const container = scrollContainerRef.current
- if (!container) return
- const updateScrollState = () => setHasOverflowTop(container.scrollTop > 1)
- updateScrollState()
- container.addEventListener('scroll', updateScrollState, { passive: true })
- const observer = new ResizeObserver(updateScrollState)
- observer.observe(container)
- if (scrollContentRef.current) observer.observe(scrollContentRef.current)
- return () => {
- container.removeEventListener('scroll', updateScrollState)
- observer.disconnect()
- }
- }, [isCollapsed])
+ const scrollEdges = useScrollEdges(scrollContainerRef, {
+ contentRef: scrollContentRef,
+ enabled: !isCollapsed,
+ })
return (
<>
-
+ {/* The divider is the pinned block's bottom rule, not the scroll region's top one:
+ the region's edge fade masks its own first pixels, which would erase a rule
+ drawn there exactly when it should show. Same construction as the footer. */}
+
{/* Both stay buttons, not Links: leaving settings must run the unsaved-changes guard. */}
{SETTINGS_PLANE_CHROME[plane].showWordmark ? (