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({
- {hasOnlySSO && } + {hasOnlySSO && ( + + )} {emailEnabled && (
@@ -486,10 +488,13 @@ function SignupFormContent({ githubAvailable={githubAvailable} googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} - callbackURL={redirectUrl || '/workspace'} + callbackURL={redirectUrl || DEFAULT_POST_AUTH_ROUTE} > {ssoEnabled && !hasOnlySSO && ( - + )} )} diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 96bd55f1f88..c2fbe5d4dbb 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -6,7 +6,7 @@ import { normalizeEmail } from '@sim/utils/string' import { useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { validateCallbackUrl } from '@/lib/core/security/input-validation' -import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' +import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' const logger = createLogger('useVerification') @@ -16,7 +16,7 @@ const logger = createLogger('useVerification') * * Both redirect sites run in the same commit as the effect that reads session * storage, so a cached value is still `null` when they fire and the stored - * destination is silently replaced by `/workspace`. Reading here removes that + * destination is silently replaced by the default entry. Reading here removes that * race. `redirectAfter` wins over the stored URL; anything failing callback * validation is discarded, and an unsafe stored value is evicted. */ @@ -112,7 +112,8 @@ export function useVerification({ logger.warn('Failed to refetch session after verification', e) } - const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace' + const destination = + resolveRedirectUrl(searchParams.get('redirectAfter')) ?? DEFAULT_POST_AUTH_ROUTE sessionStorage.removeItem('verificationEmail') sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) @@ -217,7 +218,7 @@ export function useVerification({ if (destination) { window.location.href = destination } else { - router.push('/workspace') + router.push(DEFAULT_POST_AUTH_ROUTE) } } diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx index 8d14e3a1ee9..31aa3527fb9 100644 --- a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -2,6 +2,7 @@ import { Button } from '@sim/emcn' import { useRouter } from 'next/navigation' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' interface ChatErrorStateProps { error: string @@ -19,10 +20,10 @@ export function ChatErrorState({ error }: ChatErrorStateProps) {

{error}

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. */} +
+ {tabs?.map((item) => { + const active = item.id === activeTab + return ( + setTab(item.id === defaultTab ? null : item.id)} + className='min-w-[44px] shrink-0 text-center' + > + {item.label} + + ) + })} +
+
+ {searchOpen ? ( + setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') closeSearch() + }} + endAdornment={ + + } + /> + ) : ( + setSearchOpened(true)} /> + )} + {action && {action}} +
+
+
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts new file mode 100644 index 00000000000..2be9ae79290 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts @@ -0,0 +1,22 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for an organization page's + * header. Both are view state the page's content filters by, so a link carries + * them and a tab switch keeps them. + * + * - `tab` is the active header tab. Absent, the page shows its first tab, so the + * key only appears once the viewer leaves it. + * - `q` is the search field's text, written raw (consumers trim on read) and + * debounced on the way to the URL by `useDebouncedSearchSetter`. + */ +export const organizationPageParsers = { + tab: parseAsString, + q: parseAsString.withDefault(''), +} as const + +/** Tabs and search are filter-like view changes, not navigation: replace, and clear at the default. */ +export const organizationPageUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts new file mode 100644 index 00000000000..371ae5527af --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useQueryStates } from 'nuqs' +import { + organizationPageParsers, + organizationPageUrlKeys, +} from '@/app/o/[organizationId]/components/organization-page/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +/** + * The header filters of the organization page the caller sits on. The shell + * drives them; a page's content reads `tab` and `search` to filter its list, so + * the same criteria apply whichever tab is showing. + */ +export function useOrganizationPageFilters() { + const [{ tab, q }, setFilters] = useQueryStates(organizationPageParsers, organizationPageUrlKeys) + + const setTab = useCallback((next: string | null) => setFilters({ tab: next }), [setFilters]) + const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ q: value }, options)) + + return { tab, search: q, setTab, setSearch } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx new file mode 100644 index 00000000000..c95da914201 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' + +const hoverState = vi.hoisted(() => ({ isOpen: false })) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ + useHoverMenu: () => ({ + isOpen: hoverState.isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() }, + }), +})) + +import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section' + +const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({ + id: `chat-${index + 1}`, + name: `Chat ${index + 1}`, + href: `/o/org-1/chat/chat-${index + 1}`, +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + hoverState.isOpen = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(props: Partial[0]> = {}) { + await act(async () => { + root.render( + {}} + onMoreClick={() => {}} + {...props} + /> + ) + }) +} + +describe('ChatsSection', () => { + it('lists every chat with no paging control', async () => { + await render() + + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + expect(container.textContent).not.toContain('See more') + }) + + it('marks the chat on the current route active', async () => { + await render({ pathname: '/o/org-1/chat/chat-3' }) + + const current = container.querySelector('a[href="/o/org-1/chat/chat-3"]') + const other = container.querySelector('a[href="/o/org-1/chat/chat-4"]') + expect(current?.className).toContain('surface-active') + expect(other?.className).not.toContain('surface-active') + }) + + it('reports the row href when its options button is pressed', async () => { + const onMoreClick = vi.fn() + await render({ onMoreClick }) + + const button = container.querySelector( + 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + ) + await act(async () => button?.click()) + + expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2') + }) + + it('shows the empty state when there are no chats', async () => { + await render({ chats: [] }) + expect(container.textContent).toContain('No chats yet') + }) + + it('renders the flyout rows while collapsed', async () => { + hoverState.isOpen = true + await render({ isCollapsed: true }) + + expect(container.querySelector('[aria-label="Chats"]')).not.toBeNull() + /* Radix portals the flyout to the body. */ + expect(document.body.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx new file mode 100644 index 00000000000..3154dd9356a --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -0,0 +1,184 @@ +'use client' + +import { memo } from 'react' +import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' +import Link from 'next/link' +import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' + +/** Stands in for a chip row while the list loads, so it carries no margin either. */ +function ChatRowSkeleton() { + return ( +
+ +
+ ) +} + +interface ChatRowProps { + chat: OrganizationChat + isCurrentRoute: boolean + isMenuOpen: boolean + onContextMenu: (e: React.MouseEvent, href: string) => void + onMoreClick: (e: React.MouseEvent, href: string) => void +} + +const ChatRow = memo(function ChatRow({ + chat, + isCurrentRoute, + isMenuOpen, + onContextMenu, + onMoreClick, +}: ChatRowProps) { + /** + * The trailing slot fits one glyph, and the dot wins over the pin: it reports + * transient state (a run in progress, or an unread reply elsewhere), while pinning + * is persistent and already conveyed by the row sorting to the top of the list. + */ + const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread)) + + return ( + onContextMenu(e, chat.href)} + > + +
+ {showStatusDot && ( +
+ + ) +}) + +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 ? ( +
+ } + hover={hover} + ariaLabel='Chats' + > + {isLoading ? ( + + + Loading... + + ) : chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => { + const isCurrentRoute = pathname === chat.href + return ( + + onContextMenu(e, chat.href)}> + + + + ) + }) + )} + +
+ ) : ( +
+ {isLoading ? ( + + ) : ( + <> + {chats.length === 0 && ( +
+ No chats yet +
+ )} + {chats.map((chat) => ( + + ))} + + )} +
+ )} +
+ ) +} 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 ( +
+
+