diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md
index f4a88a40314..50e7684363f 100644
--- a/.claude/rules/emcn-components.md
+++ b/.claude/rules/emcn-components.md
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
- **`ChipDatePicker`** — chip-styled date field.
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
+- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region.
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead.
## Modal keyboard defaults
diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md
index 51a882f8607..61960c9eb5c 100644
--- a/.claude/rules/sim-styling.md
+++ b/.claude/rules/sim-styling.md
@@ -60,6 +60,10 @@ Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcu
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
+## Scroll Edges
+
+A scroll region that can hide rows past an edge uses `useScrollEdges` with `scrollFadeClass` + `scrollFadeAttributes` from `@sim/emcn`: a 12px fade at an edge only while content is hidden beyond it, never at rest. The region's baseline padding lives on the scroll box itself (so rows pass through it under the fade), and the divider at that edge is drawn by the neighboring block, conditional on the same edge. Never hand-roll a `mask-image` gradient or a `scrollTop > 0` effect for this.
+
## Font Weight
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
@@ -70,7 +74,7 @@ Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-we
## Color Tokens
-Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
+Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; progress and completion (a checked step, a done state) `--brand-blue` — `--selection` stays the interactive highlight; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
### Line weight
diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts
index 4b6c03016ca..df17dd4f51f 100644
--- a/apps/desktop/e2e/smoke.spec.ts
+++ b/apps/desktop/e2e/smoke.spec.ts
@@ -10,7 +10,7 @@ import { _electron as electron, expect, test } from '@playwright/test'
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
const PAGES: Record = {
- '/workspace': `Sim Fixture
+ '/home': `Sim Fixture
fixture-app
@@ -82,7 +82,7 @@ test.describe('desktop shell smoke', () => {
app = await launchApp(origin)
const window = await app.firstWindow()
await expect(window.locator('#app')).toHaveText('fixture-app')
- expect(window.url()).toBe(`${origin}/workspace`)
+ expect(window.url()).toBe(`${origin}/home`)
})
test('internal window.open creates an independent full Sim window', async () => {
@@ -150,7 +150,7 @@ test.describe('desktop shell smoke', () => {
app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal)
)
.toEqual(['https://docs.sim.ai/navigation'])
- expect(window.url()).toBe(`${origin}/workspace`)
+ expect(window.url()).toBe(`${origin}/home`)
})
test('unreachable origin shows the bundled offline page', async () => {
diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts
index 245019bdc51..6816c745736 100644
--- a/apps/desktop/src/main/app-routes.test.ts
+++ b/apps/desktop/src/main/app-routes.test.ts
@@ -5,15 +5,15 @@ describe('app routes', () => {
it('derives the new-chat route from the last workspace route', () => {
expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home')
expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home')
- expect(newChatRoute('/account')).toBe('/workspace')
- expect(newChatRoute(undefined)).toBe('/workspace')
- expect(newChatRoute('//evil.example')).toBe('/workspace')
+ expect(newChatRoute('/account')).toBe('/home')
+ expect(newChatRoute(undefined)).toBe('/home')
+ expect(newChatRoute('//evil.example')).toBe('/home')
})
it('derives the settings route from the last workspace route', () => {
expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop')
- expect(settingsRoute('/account')).toBe('/workspace')
- expect(settingsRoute(undefined)).toBe('/workspace')
- expect(settingsRoute('//evil.example')).toBe('/workspace')
+ expect(settingsRoute('/account')).toBe('/home')
+ expect(settingsRoute(undefined)).toBe('/home')
+ expect(settingsRoute('//evil.example')).toBe('/home')
})
})
diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts
index 6e877edd013..abbf62bba33 100644
--- a/apps/desktop/src/main/app-routes.ts
+++ b/apps/desktop/src/main/app-routes.ts
@@ -10,6 +10,12 @@ import { isSafeInternalPath } from '@/main/config'
* do with the tray, and the tray can be absent entirely.
*/
+/**
+ * The web app's signed-in entry. It resolves to the organization the user belongs
+ * to, or to their workspaces, so the shell never has to know which applies.
+ */
+export const APP_ENTRY_ROUTE = '/home'
+
/** Workspace id from the last visited route, or null when it carries none. */
function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
if (isSafeInternalPath(lastRoute)) {
@@ -23,19 +29,19 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
/**
* Route for "New Chat": the home (chat) surface of the workspace the user was
- * last in, falling back to the workspace picker redirect when the last route
- * carries no workspace.
+ * last in, falling back to the app entry when the last route carries no
+ * workspace.
*/
export function newChatRoute(lastRoute: string | undefined): string {
const workspaceId = workspaceIdFromRoute(lastRoute)
- return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace'
+ return workspaceId ? `/workspace/${workspaceId}/home` : APP_ENTRY_ROUTE
}
/**
* Route for "Settings…": the Sim app's settings surface for the workspace the
- * user was last in, falling back to the workspace picker redirect.
+ * user was last in, falling back to the app entry.
*/
export function settingsRoute(lastRoute: string | undefined): string {
const workspaceId = workspaceIdFromRoute(lastRoute)
- return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace'
+ return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE
}
diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts
index b359110bf90..bf441fe3f9f 100644
--- a/apps/desktop/src/main/session-lifecycle.test.ts
+++ b/apps/desktop/src/main/session-lifecycle.test.ts
@@ -75,9 +75,9 @@ describe('decideStartRoute', () => {
})
it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => {
- expect(decideStartRoute(undefined)).toBe('/workspace')
- expect(decideStartRoute('//evil.example')).toBe('/workspace')
- expect(decideStartRoute('/login')).toBe('/workspace')
+ expect(decideStartRoute(undefined)).toBe('/home')
+ expect(decideStartRoute('//evil.example')).toBe('/home')
+ expect(decideStartRoute('/login')).toBe('/home')
})
})
@@ -94,11 +94,11 @@ describe('resolveStartRoute', () => {
)
})
- it('falls back to the workspace picker after confirmed access denial', async () => {
+ it('falls back to the app entry after confirmed access denial', async () => {
const session = sessionWithResponse(403, { error: 'Workspace access denied' })
await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe(
- '/workspace'
+ '/home'
)
})
diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts
index c6d47899b2b..5a8eee85932 100644
--- a/apps/desktop/src/main/session-lifecycle.ts
+++ b/apps/desktop/src/main/session-lifecycle.ts
@@ -7,6 +7,7 @@ import {
completeAccountDataTeardown,
waitForAccountDataMutations,
} from '@/main/account-data-generation'
+import { APP_ENTRY_ROUTE } from '@/main/app-routes'
import { isSafeInternalPath } from '@/main/config'
import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation'
import type { EventRecorder } from '@/main/observability'
@@ -60,14 +61,14 @@ export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean {
/**
* Picks the route to load at launch: the last visited route (when safe and
- * not itself an auth surface), falling back to /workspace. A signed-out
+ * not itself an auth surface), falling back to the app entry. A signed-out
* partition is handled by the web app's own login redirect.
*/
export function decideStartRoute(lastRoute: string | undefined): string {
if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) {
return lastRoute
}
- return '/workspace'
+ return APP_ENTRY_ROUTE
}
function workspaceIdFromRoute(route: string): string | null {
@@ -110,8 +111,8 @@ export async function resolveStartRoute(
}
)
if (response.status === 403) {
- logger.info('Saved workspace route is no longer accessible; opening workspace picker')
- return '/workspace'
+ logger.info('Saved workspace route is no longer accessible; opening the app entry')
+ return APP_ENTRY_ROUTE
}
return route
} catch {
diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts
index 93a94dd6eae..5361878dec2 100644
--- a/apps/sim/app/(auth)/auth-redirect.test.ts
+++ b/apps/sim/app/(auth)/auth-redirect.test.ts
@@ -32,7 +32,7 @@ describe('resolvePostSignupDestination', () => {
it('never routes to verify when no mail provider is configured', () => {
expect(
resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' })
- ).toEqual({ kind: 'workspace' })
+ ).toEqual({ kind: 'entry' })
})
it('preserves the callback URL when verification is not enforceable', () => {
diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts
index 269528c0bb1..4353b5fc75d 100644
--- a/apps/sim/app/(auth)/auth-redirect.ts
+++ b/apps/sim/app/(auth)/auth-redirect.ts
@@ -1,3 +1,5 @@
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
+
/**
* Where the user goes once authentication finishes, carried across the login →
* signup → verify hops. Written only after `validateCallbackUrl` accepts it, and
@@ -8,19 +10,22 @@ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'
/** Route the verify hop lives at, entered only from signup. */
export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true'
-/** Default post-auth destination when no callback URL was carried in. */
-export const DEFAULT_POST_AUTH_ROUTE = '/workspace'
+/**
+ * Default post-auth destination when no callback URL was carried in: the app
+ * entry, which resolves to the viewer's organization or their workspaces.
+ */
+export const DEFAULT_POST_AUTH_ROUTE = APP_ENTRY_PATH
/**
* Where a successful email signup goes next.
* - `verify`: the verification hop, which owns the post-auth redirect from there
* - `redirect`: the validated callback URL the visitor arrived with
- * - `workspace`: the default destination
+ * - `entry`: the default destination, {@link DEFAULT_POST_AUTH_ROUTE}
*/
export type PostSignupDestination =
| { kind: 'verify' }
| { kind: 'redirect'; url: string }
- | { kind: 'workspace' }
+ | { kind: 'entry' }
interface PostSignupDestinationParams {
/** The server-derived effective flag — verification enabled AND deliverable. */
@@ -40,7 +45,7 @@ export function resolvePostSignupDestination({
redirectUrl,
}: PostSignupDestinationParams): PostSignupDestination {
if (emailVerificationEnabled) return { kind: 'verify' }
- return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
+ return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'entry' }
}
/** The raw redirect-carrying params, as read from a URL on client or server. */
diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx
index c200d86bd11..37df7815ed7 100644
--- a/apps/sim/app/(auth)/components/social-login-buttons.tsx
+++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
+import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
const logger = createLogger('SocialLoginButtons')
@@ -22,7 +23,7 @@ export function SocialLoginButtons({
githubAvailable,
googleAvailable,
microsoftAvailable,
- callbackURL = '/workspace',
+ callbackURL = DEFAULT_POST_AUTH_ROUTE,
children,
}: SocialLoginButtonsProps) {
const [isGithubLoading, setIsGithubLoading] = useState(false)
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index cfe0b1403b8..c2dafcb06ca 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -22,7 +22,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureClientEvent } from '@/lib/posthog/client'
-import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
+import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
import {
AuthDivider,
AuthField,
@@ -108,7 +108,7 @@ export default function LoginPage({
invalidCallbackRef.current = true
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
}
- const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
+ const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : DEFAULT_POST_AUTH_ROUTE
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
const signupHref = buildAuthCrossLink('/signup', {
callbackUrl: isValidCallbackUrl ? callbackUrl : null,
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 61ad48a8328..7488520915a 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -408,7 +408,9 @@ function SignupFormContent({
diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
index 6e182a8ef48..a279ec39f27 100644
--- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
+++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
@@ -21,6 +21,7 @@ import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib
import { client } from '@/lib/auth/auth-client'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'
import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css'
@@ -143,7 +144,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') {
setSocialLoading(provider)
try {
- await client.signIn.social({ provider, callbackURL: '/workspace' })
+ await client.signIn.social({ provider, callbackURL: APP_ENTRY_PATH })
} catch (error) {
logger.warn('Social sign-in did not complete', { provider, error })
} finally {
diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
index 2584ea88c37..8e13703055c 100644
--- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
+++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
@@ -151,12 +151,14 @@ describe('desktop title-bar surface audit', () => {
expect(rule).not.toContain('margin-top')
})
- it('drops the content pane border where the pane meets the window edge', () => {
- // Collapsing the sidebar in the desktop shell takes the pane's padding to 0, so a
- // retained border and radius drew a hairline outline inset from the square window.
+ it('drops the pane divider where the pane meets the window edge', () => {
+ // The pane meets the rail on a single left hairline. Collapsing the sidebar in the
+ // desktop shell leaves no rail beside it, so a retained divider would draw a stray
+ // line down the window's left edge. The pane carries no radius or full border to
+ // drop anymore; the divider is the only chrome between them.
const flush = '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:'
- expect(workspaceChrome).toContain(`${flush}rounded-none`)
- expect(workspaceChrome).toContain(`${flush}border-0`)
+ expect(workspaceChrome).toContain(`${flush}border-l-0`)
+ expect(workspaceChrome).not.toContain('rounded-[8px]')
})
it('clears the lane for panels that embed pages away from the lights', () => {
@@ -289,6 +291,9 @@ const SELF_RESERVE_REQUIRED = new Set([
// `WorkspaceHostProvider` — an ancestor of the chrome, not a descendant — returns it
// instead of its children on a client-side 403. Neither is a double reservation.
'app/workspace/[workspaceId]/components/workspace-access-denied.tsx',
+ // Same shape on the organization surface: `o/[organizationId]/layout.tsx` returns it
+ // for a non-member before reaching ``.
+ 'app/o/[organizationId]/components/organization-access-denied.tsx',
])
/** Every file under `app/`, so ancestor layouts can be resolved without extra fs calls. */
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 232ab9ebe9b..c99b6a04651 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -392,7 +392,7 @@
:root {
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */
- --sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
+ --sidebar-expanded-width: 256px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
--desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */
--workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */
--desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */
@@ -403,17 +403,12 @@
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
/**
- * The padding `.workspace-content-shell` insets the panel and terminal from
- * the viewport by (CONTENT_WINDOW_GAP).
- *
- * Published here because surfaces portalled to `` — the toast stack —
- * position against those elements from the viewport, so they must add back
- * whatever separates the element from the viewport edge. Reading it rather
- * than hardcoding 8px is what keeps the toast and the canvas controls on the
- * same clearance when the shell drops its padding; the controls are laid out
- * inside the shell and so need no correction.
+ * Distance between `.workspace-content-shell` and the viewport edge
+ * (CONTENT_WINDOW_GAP). The shell sits flush, so this is zero; it stays
+ * published because surfaces portalled to `` — the toast stack — and
+ * the panel and terminal geometry all read it rather than assuming a value.
*/
- --workspace-content-gap: 8px;
+ --workspace-content-gap: 0px;
--output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */
/**
* Neutral border and divider thickness. Standard-density displays cannot draw
@@ -562,14 +557,6 @@ html[data-sim-desktop-title-bar="inset"]
--workspace-content-title-bar-inset: var(--desktop-title-bar-height);
}
-/* The one case the shell drops its padding entirely (see `workspace-chrome.tsx`:
- `isCollapsed && '[[data-sim-desktop-title-bar=inset]_&]:p-0'`). Declared on the
- root so the portalled toast stack — which cannot inherit from the shell — sees
- it too, and keeps the same clearance the in-shell canvas controls keep. */
-html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sidebar-collapsed]) {
- --workspace-content-gap: 0px;
-}
-
.workspace-root code,
.workspace-root kbd,
.workspace-root samp,
@@ -580,7 +567,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
.sidebar-container {
width: var(--sidebar-width);
- transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
/**
@@ -607,12 +593,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
--sidebar-width: var(--sidebar-expanded-width);
}
-/* The card appears at full width, so the aside's own width transition would animate
- 0 -> expanded inside it. */
-.sidebar-shell-outer[data-peek] .sidebar-container {
- transition: none;
-}
-
/* The card is a flex column sized to its content, so the shell must be allowed to
shrink for the sidebar's own scroll region to bound itself once the card hits its
max height. Docked, this element is not a flex item and the rule is inert. */
@@ -623,7 +603,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
.sidebar-container span,
.sidebar-container .text-small {
- transition: opacity 120ms ease;
white-space: nowrap;
}
@@ -632,51 +611,10 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb
opacity: 0;
}
-.sidebar-container .sidebar-collapse-hide {
- transition: opacity 60ms ease;
-}
-
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
opacity: 0;
}
-@keyframes sidebar-collapse-guard {
- from {
- pointer-events: none;
- }
- to {
- pointer-events: auto;
- }
-}
-
-.sidebar-container[data-collapsed] {
- animation: sidebar-collapse-guard 250ms step-end;
-}
-
-.sidebar-container.is-resizing {
- transition: none;
-}
-
-/* Suppress width/transform transitions on the chrome wrappers during a
- drag-resize so the outer overflow-hidden clip doesn't lag behind the inner
- sidebar content, which is already at the correct width instantly. */
-html.sidebar-resizing .sidebar-shell-outer,
-html.sidebar-resizing .sidebar-shell-inner {
- transition: none !important;
-}
-
-/* Suppress sidebar transitions during the initial hydration window. The
- pre-paint script sets the correct --sidebar-width, but store rehydration
- re-applies it a tick later; without this guard that re-apply animates the
- rail, reading as a collapse -> expand flash on a fresh page load. Removed
- after the first paint (see workspace-chrome.tsx) so user-driven toggles and
- the fullscreen slide still animate. */
-html.sidebar-booting .sidebar-container,
-html.sidebar-booting .sidebar-shell-outer,
-html.sidebar-booting .sidebar-shell-inner {
- transition: none !important;
-}
-
.panel-container {
width: var(--panel-width);
}
@@ -787,6 +725,9 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
+ /* Progress and completion — the checked step, the done state. Deeper and
+ quieter than --selection, which stays the interactive highlight. */
+ --brand-blue: #3b6fe0;
--selection: #1a5cf6;
--selection-muted: #1a5cf647;
--warning: #ea580c;
@@ -948,6 +889,8 @@ html.sidebar-booting .sidebar-shell-inner {
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
+ /* Lifted for contrast on dark surfaces, the same step --selection takes. */
+ --brand-blue: #5b8def;
--selection: #4b83f7;
--selection-muted: #4b83f759;
--warning: #ff6600;
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
index 656b3efe645..f3292fea82c 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -146,7 +146,7 @@ describe('OAuth2 authorize route', () => {
const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
- expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`)
expect(mocks.requireClient).toHaveBeenCalledWith('google-email')
expect(mocks.createConnection).not.toHaveBeenCalled()
})
@@ -227,7 +227,7 @@ describe('OAuth2 authorize route', () => {
)
expect(response.headers.get('location')).toBe(
- `${BASE_URL}/workspace?error=credential_provider_mismatch`
+ `${BASE_URL}/home?error=credential_provider_mismatch`
)
})
@@ -267,9 +267,7 @@ describe('OAuth2 authorize route', () => {
})
)
- expect(response.headers.get('location')).toBe(
- `${BASE_URL}/workspace?error=workspace_access_denied`
- )
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=workspace_access_denied`)
})
it('redirects a draft launch infrastructure failure through the browser error contract', async () => {
@@ -277,7 +275,7 @@ describe('OAuth2 authorize route', () => {
const response = await GET(request({ draftId: 'draft-1' }))
- expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
+ expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`)
})
it('routes custom providers through the exact application draft', async () => {
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts
index 1a70cc24ad2..6c1b9e9d4b6 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts
@@ -13,6 +13,7 @@ import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/app
import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection'
import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection'
import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config'
import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants'
import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state'
@@ -64,7 +65,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
if (!(error instanceof OrchestrationError)) throw error
logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code })
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`)
}
}
@@ -83,7 +84,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
: connectionCompleteUrl.toString()
: requestedCallback?.startsWith(`${baseUrl}/`)
? requestedCallback
- : `${baseUrl}/workspace`
+ : `${baseUrl}${APP_ENTRY_PATH}`
if (!fromConnectionDraft) {
try {
@@ -100,22 +101,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
connectionDraftId = connection.draftId
} catch (error) {
if (error instanceof CredentialConnectionProviderMismatchError) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
+ return NextResponse.redirect(
+ `${baseUrl}${APP_ENTRY_PATH}?error=credential_provider_mismatch`
+ )
}
if (
credentialId &&
error instanceof ForbiddenOperationError &&
error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED'
) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=credential_access_denied`)
}
if (error instanceof OrchestrationError && error.code === 'not_found') {
return NextResponse.redirect(
- `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}`
+ `${baseUrl}${APP_ENTRY_PATH}?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}`
)
}
if (error instanceof OrchestrationError && error.code === 'forbidden') {
- return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=workspace_access_denied`)
}
throw error
}
@@ -183,7 +186,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
providerId,
status: linkResponse.status,
})
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`)
}
const response = NextResponse.redirect(payload.url)
@@ -198,6 +201,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return response
} catch (error) {
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
- return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`)
}
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
index 19284a950fc..afca4590c1f 100644
--- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
@@ -18,6 +18,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { safeAccountInsert } from '@/lib/oauth/credential-service'
import {
parseInstagramLongLivedToken,
@@ -52,7 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`))
+ return clearOAuthCookies(
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`)
+ )
}
const parsed = await parseRequest(instagramCallbackContract, request, {})
@@ -68,7 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error_description,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_access_denied`)
)
}
@@ -79,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
hasCookieState: Boolean(cookieState),
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_state_mismatch`)
)
}
@@ -90,7 +93,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!code) {
logger.error('No authorization code received from Instagram')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_code`)
)
}
@@ -123,7 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_token_error`)
)
}
@@ -136,7 +139,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!shortLived) {
logger.error('Instagram short-lived token response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_token`)
)
}
@@ -160,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_exchange_error`)
)
}
@@ -174,7 +177,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!longLived) {
logger.error('Instagram long-lived token response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_long_lived`)
)
}
@@ -199,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: errorText,
})
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`)
)
}
@@ -212,7 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!profile) {
logger.error('Instagram profile response was invalid')
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`)
)
}
@@ -222,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!igUserId) {
logger.error('Instagram profile response missing user_id', { profile })
return clearOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_user_id`)
)
}
@@ -311,7 +314,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value
const redirectUrl =
- returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace`
+ returnUrlCookie && isSameOrigin(returnUrlCookie)
+ ? returnUrlCookie
+ : `${baseUrl}${APP_ENTRY_PATH}`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('instagram_connected', 'true')
@@ -322,6 +327,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
? 'instagram_config_error'
: 'instagram_callback_error'
- return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`))
+ return clearOAuthCookies(
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`)
+ )
}
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
index 51a36fd3c98..1dd57e3d27a 100644
--- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts
@@ -140,7 +140,7 @@ describe('QuickBooks OAuth callback', () => {
expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled()
expect(response.headers.get('location')).toBe(
- 'https://sim.test/workspace?error=quickbooks_callback_error'
+ 'https://sim.test/home?error=quickbooks_callback_error'
)
})
})
diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
index 675e7e8a076..f232cb655f6 100644
--- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts
@@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { normalizeQuickBooksRealmId } from '@/lib/oauth/quickbooks'
import { parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state'
@@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
const baseUrl = getBaseUrl()
- const fallbackUrl = `${baseUrl}/workspace`
+ const fallbackUrl = `${baseUrl}${APP_ENTRY_PATH}`
let validatedReturnUrl: URL | null = null
try {
diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
index 8447e56d48d..7df3318106e 100644
--- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
+++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts
@@ -12,6 +12,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify'
import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state'
@@ -63,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
- return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`)
}
const { searchParams } = request.nextUrl
@@ -79,28 +80,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!validateHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_hmac_invalid`)
}
if (!state) {
logger.error('Missing state in Shopify OAuth callback')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_state_mismatch`)
}
if (!code) {
logger.error('No code received from Shopify')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_code`)
}
const shopDomain = shop
if (!shopDomain) {
logger.error('No shop domain available')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_shop`)
}
if (!shopifyShopDomainSchema.safeParse(shopDomain).success) {
logger.error('Invalid shop domain format:', { shopDomain })
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_invalid_shop`)
}
const { draftId, returnUrl } = parseShopifyOAuthState({
@@ -128,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
status: tokenResponse.status,
body: errorText,
})
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_token_error`)
}
const tokenData = await tokenResponse.json()
@@ -142,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (!accessToken) {
logger.error('No access token in response')
- return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`)
+ return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_token`)
}
await completeShopifyOAuthConnection({
@@ -157,7 +158,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
if (returnUrl && !isSameOrigin(returnUrl)) {
throw new Error('Shopify OAuth state contains an invalid return URL')
}
- const redirectUrl = returnUrl ?? `${baseUrl}/workspace`
+ const redirectUrl = returnUrl ?? `${baseUrl}${APP_ENTRY_PATH}`
const finalUrl = new URL(redirectUrl)
finalUrl.searchParams.set('shopify_connected', 'true')
@@ -169,7 +170,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
? 'shopify_config_error'
: 'shopify_callback_error'
return clearShopifyOAuthCookies(
- NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
+ NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`)
)
}
})
diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts
index 2d45e1dca3b..bbb96651bbb 100644
--- a/apps/sim/app/api/auth/trello/callback/route.ts
+++ b/apps/sim/app/api/auth/trello/callback/route.ts
@@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const logger = createLogger('TrelloCallback')
@@ -48,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const returnUrl =
requestedReturnUrl && isSameOrigin(requestedReturnUrl)
? requestedReturnUrl
- : `${baseUrl}/workspace`
+ : `${baseUrl}${APP_ENTRY_PATH}`
const queryState = parsed.data.query.state
const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value
diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts
index 14b5d2bd4a8..7a11c742bbc 100644
--- a/apps/sim/app/api/billing/portal/route.ts
+++ b/apps/sim/app/api/billing/portal/route.ts
@@ -10,6 +10,7 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
const logger = createLogger('BillingPortal')
@@ -28,7 +29,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const context = parsedBody.data.context
const organizationId = parsedBody.data.organizationId
- const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated`
+ const returnUrl =
+ parsedBody.data.returnUrl || `${getBaseUrl()}${APP_ENTRY_PATH}?billing=updated`
const stripe = requireStripeClient()
diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
index 65e2fe04550..cfc34b07b96 100644
--- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
+++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts
@@ -84,7 +84,7 @@ describe('Enterprise owner claim routes', () => {
mocks.acceptClaim.mockResolvedValue({
success: true,
claim,
- redirectPath: '/workspace',
+ redirectPath: '/home',
})
const response = await POST(
diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts
index 5e261f4b326..7fce432cca5 100644
--- a/apps/sim/app/api/workspaces/[id]/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/route.ts
@@ -78,11 +78,10 @@ export const PATCH = withRouteHandler(
try {
const body = parsed.data.body
- const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
+ const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body
if (
name === undefined &&
- color === undefined &&
logoUrl === undefined &&
billedAccountUserId === undefined &&
allowPersonalApiKeys === undefined
@@ -106,10 +105,6 @@ export const PATCH = withRouteHandler(
updateData.name = name
}
- if (color !== undefined) {
- updateData.color = color
- }
-
if (logoUrl !== undefined) {
updateData.logoUrl = logoUrl
}
@@ -198,7 +193,6 @@ export const PATCH = withRouteHandler(
metadata: {
changes: {
...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }),
- ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }),
...(logoUrl !== undefined && {
logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl },
}),
diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts
index 50033c1d51c..c5b398fa3f1 100644
--- a/apps/sim/app/api/workspaces/route.ts
+++ b/apps/sim/app/api/workspaces/route.ts
@@ -123,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
try {
const parsed = await parseRequest(createWorkspaceContract, req, {})
if (!parsed.success) return parsed.response
- const { name, color, skipDefaultWorkflow } = parsed.data.body
+ const { name, skipDefaultWorkflow } = parsed.data.body
const activeOrganizationId = getActiveOrganizationId(session)
const creationPolicy = await getWorkspaceCreationPolicy({
userId: session.user.id,
@@ -153,7 +153,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
userId: session.user.id,
name,
skipDefaultWorkflow,
- explicitColor: color,
organizationId: creationPolicy.organizationId,
workspaceMode: creationPolicy.workspaceMode,
billedAccountUserId: creationPolicy.billedAccountUserId,
@@ -188,7 +187,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
description: `Created workspace "${newWorkspace.name}"`,
metadata: {
name: newWorkspace.name,
- color: newWorkspace.color,
workspaceMode: newWorkspace.workspaceMode,
organizationId: newWorkspace.organizationId,
},
diff --git a/apps/sim/app/desktop/connect/switch-account.tsx b/apps/sim/app/desktop/connect/switch-account.tsx
index 4f132144855..d2be918b9e9 100644
--- a/apps/sim/app/desktop/connect/switch-account.tsx
+++ b/apps/sim/app/desktop/connect/switch-account.tsx
@@ -14,7 +14,7 @@ interface SwitchAccountProps {
* callback.
*
* A plain link to `/login` would not work: the middleware bounces `/login` back
- * to `/workspace` while any session cookie is set, so the wrong account has to
+ * to the app entry while any session cookie is set, so the wrong account has to
* be cleared before the login page is reachable at all. For the same reason a
* failed sign-out must not navigate — it would land the user right back where
* they started with no explanation.
diff --git a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
index c5a0d690700..371c0b5420b 100644
--- a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
+++ b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx
@@ -9,6 +9,7 @@ import {
type EnterpriseOwnerClaimDetails,
} from '@/lib/api/contracts/enterprise-owner-claims'
import { client, useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useEnterpriseOwnerClaimDetails } from '@/hooks/queries/enterprise-owner-claims'
@@ -265,7 +266,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris
label: 'Sign in to Enterprise',
onClick: async () => {
await client.signOut()
- router.push(authLink('/login', '/workspace'))
+ router.push(authLink('/login', APP_ENTRY_PATH))
},
},
]
diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx
new file mode 100644
index 00000000000..90760f97c44
--- /dev/null
+++ b/apps/sim/app/home/page.test.tsx
@@ -0,0 +1,46 @@
+/**
+ * @vitest-environment node
+ */
+import { authMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRedirect, mockResolveAppEntryPath } = vi.hoisted(() => ({
+ mockRedirect: vi.fn((path: string) => {
+ throw new Error(`NEXT_REDIRECT:${path}`)
+ }),
+ mockResolveAppEntryPath: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: mockRedirect,
+}))
+
+vi.mock('@/lib/navigation/resolve-app-entry', () => ({
+ resolveAppEntryPath: mockResolveAppEntryPath,
+}))
+
+import AppEntryPage from '@/app/home/page'
+
+const mockGetSession = authMockFns.mockGetSession
+
+describe('AppEntryPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('sends a signed-out visitor to login without resolving an entry', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login')
+ expect(mockResolveAppEntryPath).not.toHaveBeenCalled()
+ })
+
+ it('forwards a signed-in viewer to their resolved entry', async () => {
+ const session = { user: { id: 'viewer' } }
+ mockGetSession.mockResolvedValue(session)
+ mockResolveAppEntryPath.mockResolvedValue('/o/org-1/home')
+
+ await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
+ expect(mockResolveAppEntryPath).toHaveBeenCalledWith(session)
+ })
+})
diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx
new file mode 100644
index 00000000000..1965f6894c2
--- /dev/null
+++ b/apps/sim/app/home/page.tsx
@@ -0,0 +1,18 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry'
+
+/**
+ * The signed-in app's front door. Nothing renders here: the viewer is forwarded to
+ * their organization's home, or to their workspaces when they belong to none. Every
+ * default post-auth destination points at this route, so where a viewer lands is
+ * decided once, on the server, with their membership in hand.
+ */
+export default async function AppEntryPage() {
+ const session = await getSession()
+ if (!session?.user) {
+ redirect('/login')
+ }
+
+ redirect(await resolveAppEntryPath(session))
+}
diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx
index ffc75e40866..f762c4ae795 100644
--- a/apps/sim/app/invite/[id]/invite.tsx
+++ b/apps/sim/app/invite/[id]/invite.tsx
@@ -10,6 +10,7 @@ import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
import { client, useSession } from '@/lib/auth/auth-client'
+import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useInvitationDetails } from '@/hooks/queries/invitations'
@@ -459,7 +460,7 @@ export default function Invite({ registrationDisabled }: InviteProps) {
description={error.message}
icon='users'
actions={[
- { label: 'Manage Team Settings', onClick: () => router.push('/workspace') },
+ { label: 'Manage Team Settings', onClick: () => router.push(APP_ENTRY_PATH) },
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index e3671e8792e..891cb5ed954 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -102,19 +102,22 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
} catch (e) {}
+ // The organization surface (/o/...) shares the workspace chrome and
+ // needs the same variables set before first paint.
try {
var path = window.location.pathname;
- if (path.indexOf('/workspace/') === -1) {
+ if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0) {
return;
}
} catch (e) {
return;
}
- // Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts:
- // the upper bound can never fall below the 238px minimum, so a narrow
- // window yields a width >= MIN instead of a sub-minimum sliver.
- var defaultSidebarWidth = 238;
+ // Sidebar width. Mirror getMaxSidebarWidth() in stores/sidebar/store.ts:
+ // 30% of the viewport capped at 400px, and never below the 256px
+ // minimum, so a narrow window yields a width >= MIN instead of a
+ // sub-minimum sliver.
+ var defaultSidebarWidth = 256;
try {
// Collapse comes from the cookie (independent of localStorage
// parsing); the persisted width is read defensively below. Match the
@@ -140,10 +143,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// collapsed, because the desktop hover-peek renders the sidebar at
// its restore width while --sidebar-width still reads collapsed.
var width = state && state.sidebarWidth;
- var maxSidebarWidth = Math.max(238, window.innerWidth * 0.3);
+ var maxSidebarWidth = Math.max(256, Math.min(400, window.innerWidth * 0.3));
var expandedWidth =
typeof width === 'number' && isFinite(width)
- ? Math.min(Math.max(width, 238), maxSidebarWidth)
+ ? Math.min(Math.max(width, 256), maxSidebarWidth)
: defaultSidebarWidth;
document.documentElement.style.setProperty(
'--sidebar-expanded-width',
diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts
index 23e600614a0..a0e5f077e0c 100644
--- a/apps/sim/app/manifest.ts
+++ b/apps/sim/app/manifest.ts
@@ -1,4 +1,5 @@
import type { MetadataRoute } from 'next'
+import { WORKSPACES_PATH } from '@/lib/navigation/paths'
import { getBrandConfig } from '@/ee/whitelabeling'
export const dynamic = 'force-dynamic'
@@ -43,7 +44,7 @@ export default function manifest(): MetadataRoute.Manifest {
name: 'Create Workflow',
short_name: 'New',
description: 'Create a new AI workflow',
- url: '/workspace',
+ url: WORKSPACES_PATH,
},
],
lang: 'en-US',
diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
new file mode 100644
index 00000000000..db9b5de630a
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Chat',
+}
+
+export default function OrganizationChatPage() {
+ return null
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
new file mode 100644
index 00000000000..6a4bb7647f1
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx
@@ -0,0 +1,27 @@
+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 OrganizationAccessDenied() {
+ return (
+
+
+
+
+
+
+
+
Organization access denied
+
+ You are not a member of this organization. Ask an organization admin to add you, or head
+ back to your workspaces.
+
+
+
+ View your workspaces
+
+
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
new file mode 100644
index 00000000000..e8d14d3a283
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts
@@ -0,0 +1,2 @@
+export { OrganizationPage, type OrganizationPageTab } from './organization-page'
+export { useOrganizationPageFilters } from './use-organization-page-filters'
diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
new file mode 100644
index 00000000000..26ddbba0bc3
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx
@@ -0,0 +1,173 @@
+'use client'
+
+import { type ReactNode, useRef, useState } from 'react'
+import {
+ Button,
+ Chip,
+ ChipInput,
+ cn,
+ scrollFadeAttributes,
+ scrollFadeClass,
+ scrollFadeXClass,
+ useScrollEdges,
+} from '@sim/emcn'
+import { Search, X } from '@sim/emcn/icons'
+import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
+import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
+import {
+ SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
+ SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
+
+/** The home surface's reading column, so every organization page shares its width. */
+const COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
+
+export interface OrganizationPageTab {
+ id: string
+ label: string
+}
+
+interface OrganizationPageProps {
+ title: string
+ description?: string
+ /** Header tabs; the first is the default. Omit for a page with one view. */
+ tabs?: readonly OrganizationPageTab[]
+ /** Label of the page's primary action chip. Omit for a page without one. */
+ action?: string
+ children?: ReactNode
+}
+
+/**
+ * The shell every organization page renders into: the top bar the workspace pages
+ * wear, then a fixed page header — title, description, tabs, search, and the
+ * optional action — over a scroll region that fades at both edges the way the
+ * sidebar does. Pages supply their content and logic; nothing else.
+ *
+ * The shell paints at once and never waits on data: a page renders each piece —
+ * a tab, a list, a count — the moment it is known and nothing before, with no
+ * skeleton standing in for it. Pass `tabs` only once they are known; the row
+ * simply gains them.
+ */
+export function OrganizationPage({
+ title,
+ description,
+ tabs,
+ action,
+ children,
+}: OrganizationPageProps) {
+ const scrollContainerRef = useRef(null)
+ const scrollContentRef = useRef(null)
+ const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef })
+ const tabsRef = useRef(null)
+ const tabEdges = useScrollEdges(tabsRef, { axis: 'x' })
+
+ const { tab, search, setTab, setSearch } = useOrganizationPageFilters()
+ const defaultTab = tabs?.[0]?.id
+ const activeTab = tab ?? defaultTab
+
+ /**
+ * The field stays open while it holds text, across tab switches and reloads,
+ * since the text lives in the URL; this only remembers an empty field the
+ * viewer opened and has not dismissed.
+ */
+ const [searchOpened, setSearchOpened] = useState(false)
+ const searchOpen = searchOpened || search.length > 0
+
+ const closeSearch = () => {
+ setSearch('')
+ setSearchOpened(false)
+ }
+
+ return (
+
+ {/* Reserved even while empty so the page header sits where the workspace's does. */}
+
+
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+ {/* The row yields to the controls beside it and scrolls sideways under a fade
+ once it can no longer fit; the scrollbar itself never shows. */}
+
+
+ )
+})
+
+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]/error.tsx b/apps/sim/app/o/[organizationId]/error.tsx
new file mode 100644
index 00000000000..42dca68dcb2
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/error.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import {
+ type ErrorBoundaryProps,
+ ErrorState,
+} from '@/app/workspace/[workspaceId]/components/error/error'
+
+export default function OrganizationError({ error, reset }: ErrorBoundaryProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
new file mode 100644
index 00000000000..88bee5d3e57
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx
@@ -0,0 +1,106 @@
+'use client'
+
+import { useState } from 'react'
+import { Button, cn, Tooltip } from '@sim/emcn'
+import { ArrowUp, Mic, Paperclip, Plus, Slash } from '@sim/emcn/icons'
+
+const TOOL_BUTTON_CLASS = 'size-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
+const TOOL_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
+
+const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors'
+const SEND_BUTTON_ACTIVE =
+ 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
+const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'
+
+/**
+ * The organization home composer. Mirrors the workspace chat input's chrome:
+ * the framed field, the resource, attachment, and skill triggers on the left,
+ * and voice input plus send on the right.
+ */
+export function Composer() {
+ const [draft, setDraft] = useState('')
+ const canSubmit = draft.trim().length > 0
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/index.ts b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
new file mode 100644
index 00000000000..c99ba66e037
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/composer/index.ts
@@ -0,0 +1 @@
+export { Composer } from './composer'
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
new file mode 100644
index 00000000000..b8bbcdc5fca
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import { useState } from 'react'
+import { cn, Expandable, ExpandableContent } from '@sim/emcn'
+import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
+import Link from 'next/link'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+
+type OrganizationRoute = keyof Omit, 'root' | 'chat'>
+
+type StepId = 'connect-integration' | 'create-skill' | 'create-workspace' | 'connect-sim-search'
+
+interface GetStartedStep {
+ id: StepId
+ label: string
+ /** Where the row leads. Steps without a route render as plain actions. */
+ route?: OrganizationRoute
+}
+
+/** The onboarding steps, in the order a new organization works through them. */
+const STEPS: readonly GetStartedStep[] = [
+ { id: 'connect-integration', label: 'Connect an integration', route: 'integrations' },
+ { id: 'create-skill', label: 'Create a skill', route: 'skills' },
+ { id: 'create-workspace', label: 'Create a workspace', route: 'workspaces' },
+ { id: 'connect-sim-search', label: 'Connect Sim Search MCP' },
+]
+
+const ROW_CLASS =
+ 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]'
+
+/**
+ * A step's leading mark: an empty ring until the step is done, then the
+ * completion blue filled behind a check. The check is drawn here at the ring's
+ * own scale rather than with the 24-unit house icon — scaled to 10px, that
+ * stroke thins to a hair and its optical center drifts above the box. The svg
+ * fills the ring's 14px content box (16px less the 1px border on each side), so
+ * the path's center is the ring's center, and its stroke lands at ~1px — the
+ * weight the house icons render at 16px.
+ */
+function StepMark({ complete }: { complete: boolean }) {
+ return (
+
+ {complete && (
+
+ )}
+
+ )
+}
+
+/**
+ * The organization home's onboarding list under the composer. Same chrome as
+ * the workspace home's suggested actions: a hover-revealed disclosure header
+ * over hairline-separated rows.
+ */
+export function GetStarted() {
+ const { organization } = useOrganizationContext()
+ const routes = organizationRoutes(organization.id)
+ const { workspaces } = useOrganizationWorkspaces(organization.id)
+
+ /** Which steps the organization has already taken. */
+ const completed: Record = {
+ 'connect-integration': false,
+ 'create-skill': false,
+ 'create-workspace': workspaces.length > 0,
+ 'connect-sim-search': false,
+ }
+
+ const [expanded, setExpanded] = useState(true)
+ /**
+ * Collapsible animations are enabled only after the first user toggle, so
+ * the initially-open, server-rendered panel appears at full height on first
+ * paint instead of replaying the open animation and shifting the input
+ * above it.
+ */
+ const [animationsEnabled, setAnimationsEnabled] = useState(false)
+
+ const handleToggleExpanded = () => {
+ setAnimationsEnabled(true)
+ setExpanded((prev) => !prev)
+ }
+
+ return (
+
+ {/* Full width so the whole line toggles, not just the label and chevron. */}
+
+
+
+ {/* 6px, matching a sidebar section header to its first item — both headers
+ are an 18px box around 12px text, so equal padding reads as equal
+ distance. Padding an inner wrapper rather than the animated element:
+ `collapsible-up`/`-down` interpolate height alone, so a margin here
+ would hold its full value through the close and then vanish on unmount,
+ snapping the content below up. */}
+
+
+
+ )
+}
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..9cb21a6dac8
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/skills/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next'
+import { Skills } from './skills'
+
+export const metadata: Metadata = {
+ title: 'Skills',
+}
+
+export default function OrganizationSkillsPage() {
+ return
+}
diff --git a/apps/sim/app/o/[organizationId]/skills/skills.tsx b/apps/sim/app/o/[organizationId]/skills/skills.tsx
new file mode 100644
index 00000000000..a42895bc118
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/skills/skills.tsx
@@ -0,0 +1,31 @@
+'use client'
+
+import { useMemo } from 'react'
+import {
+ OrganizationPage,
+ type OrganizationPageTab,
+} from '@/app/o/[organizationId]/components/organization-page'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+
+/** The organization's skills, filtered to everyone's, the viewer's own, or the organization's shared set. */
+export function Skills() {
+ const { organization } = useOrganizationContext()
+
+ const tabs = useMemo(
+ () => [
+ { id: 'all', label: 'All' },
+ { id: 'mine', label: 'Mine' },
+ { id: 'organization', label: organization.name },
+ ],
+ [organization.name]
+ )
+
+ return (
+
+ )
+}
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..078ce5cf229
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/workspaces/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from 'next'
+import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page'
+
+export const metadata: Metadata = {
+ title: 'Workspaces',
+}
+
+/** Workspaces by the viewer's permission level in each. */
+const TABS = [
+ { id: 'all', label: 'All' },
+ { id: 'admin', label: 'Admin' },
+ { id: 'write', label: 'Write' },
+ { id: 'read', label: 'Read' },
+] as const
+
+export default function OrganizationWorkspacesPage() {
+ return (
+
+ )
+}
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 ? (