diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index a332d09b190..4e4dd46b9f2 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -63,19 +63,16 @@ export const GET = withRouteHandler( } /** - * Disclosure-only: a preview failure must never block viewing or - * accepting the invitation itself — but it also must not read as - * "nothing moves", so failures are flagged for the client to show a - * generic migration notice. Expired-but-still-pending rows get no - * preview — acceptance deterministically rejects them. + * Supplies the disclosure token acceptance is checked against, so a preview + * failure must never block viewing or accepting the invitation itself — the + * accept path simply runs without the guard. Expired-but-still-pending rows + * get no preview; acceptance deterministically rejects them. */ let joinPreview = null - let joinPreviewUnavailable = false if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) { try { joinPreview = await getInvitationJoinPreview(session.user.id, inv) } catch (previewError) { - joinPreviewUnavailable = true logger.warn('Failed to compute invitation join preview', { invitationId: id, error: previewError, @@ -85,7 +82,6 @@ export const GET = withRouteHandler( return NextResponse.json({ joinPreview, - joinPreviewUnavailable, invitation: { id: inv.id, kind: inv.kind, diff --git a/apps/sim/app/api/pinned-items/route.ts b/apps/sim/app/api/pinned-items/route.ts index 6a159df3eef..bf31285fb61 100644 --- a/apps/sim/app/api/pinned-items/route.ts +++ b/apps/sim/app/api/pinned-items/route.ts @@ -2,7 +2,7 @@ import { db, pinnedItem } from '@sim/db' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPinnedItemContract, @@ -59,7 +59,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { and( eq(pinnedItem.userId, session.user.id), eq(pinnedItem.workspaceId, workspaceId), - resourceType ? eq(pinnedItem.resourceType, resourceType) : undefined + /** + * A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise + * appear in this workspace's unscoped listing as a resource *inside* itself. + * It is read from the workspace-list payload instead, so it is excluded here + * rather than left for a future unscoped caller to mistake for a real resource. + */ + resourceType + ? eq(pinnedItem.resourceType, resourceType) + : ne(pinnedItem.resourceType, 'workspace') ) ) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 5ec3978dd8e..a3a537101ac 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -53,11 +53,16 @@ export const GET = withRouteHandler(async (request: Request) => { activeOrganizationId, scope, }) - const { lastActiveWorkspaceId, creationPolicy } = payload + const { lastActiveWorkspaceId, pinnedWorkspaceIds, creationPolicy } = payload if (scope === 'active' && payload.workspaces.length === 0) { if (!creationPolicy.canCreate) { - return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy }) + return NextResponse.json({ + workspaces: [], + lastActiveWorkspaceId, + pinnedWorkspaceIds, + creationPolicy, + }) } let defaultWorkspace: Awaited> @@ -100,6 +105,7 @@ export const GET = withRouteHandler(async (request: Request) => { return NextResponse.json({ workspaces: [defaultWorkspace], lastActiveWorkspaceId, + pinnedWorkspaceIds, creationPolicy: refreshedCreationPolicy, }) } diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index bf40c712f9e..adbd0940b18 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { formatQuotedNameList } from '@sim/utils/string' import { useQueryClient } from '@tanstack/react-query' @@ -11,11 +10,6 @@ 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 { - buildMembershipNotice, - buildWorkspaceMigrationNotice, - MAX_LISTED_WORKSPACE_NAMES, -} from '@/lib/invitations/disclosure-copy' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' import { useInvitationDetails } from '@/hooks/queries/invitations' import { organizationKeys } from '@/hooks/queries/organization' @@ -25,6 +19,9 @@ import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('InviteById') +/** Workspace names listed in the invitation title before collapsing into an "and N more" tail. */ +const MAX_LISTED_WORKSPACE_NAMES = 3 + function runBestEffortCacheRefresh(cache: string, refresh: () => Promise): void { void Promise.resolve() .then(refresh) @@ -233,7 +230,6 @@ export default function Invite() { }) const invitation = invitationQuery.data?.invitation ?? null const joinPreview = invitationQuery.data?.joinPreview ?? null - const joinPreviewUnavailable = invitationQuery.data?.joinPreviewUnavailable === true const isLoading = Boolean(session?.user) && invitationQuery.isPending const fetchError = invitationQuery.error @@ -491,53 +487,13 @@ export default function Invite() { } const isOrg = invitation?.kind === 'organization' - /** - * Prefer the preview's organization (the one acceptance will really join) - * over the invitation's stamped name — a granted workspace may have moved - * organizations since the invite was sent. - */ - const organizationLabel = - joinPreview?.organizationName || invitation?.organizationName || 'the organization' - /** - * When the server could not compute the preview, fall back to a generic - * migration notice for membership invites — a missing preview must never - * read as "nothing moves". - */ - const migrationNotice = buildWorkspaceMigrationNotice({ - joinPreview, - joinPreviewUnavailable, - membershipIntent: invitation?.membershipIntent, - organizationLabel, - }) - /** - * Only disclosed when the invitation actually carries organization standing — - * a personal-workspace invite has no seat or membership to explain. - */ - const membershipNotice = buildMembershipNotice({ - joinPreview, - membershipIntent: invitation?.membershipIntent, - isOrganizationAdminRole: Boolean(invitation?.role && isOrgAdminRole(invitation.role)), - organizationLabel, - /** - * A personal-workspace invite has no organization id and no organization - * name yet — acceptance creates one by converting the billed owner's Pro to - * Team — so a `will-join` outcome is the authoritative signal that a - * membership and seat are involved. Gating on the ids alone silenced the - * disclosure for exactly the case that creates the membership. - */ - isOrganizationScoped: Boolean( - invitation?.organizationId || - joinPreview?.organizationName || - joinPreview?.outcome === 'will-join' - ), - }) return ( void @@ -133,38 +97,32 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa {!invitations || invitations.length === 0 ? (

No pending invitations.

) : ( - invitations.map((inv) => { - const disclosure = invitationDisclosure(inv) - return ( -
-
-

{invitationLabel(inv)}

-

- {invitationSubLabel(inv)} -

- {disclosure ? ( -

{disclosure}

- ) : null} -
- void handleAccept(inv)} - className='flex-shrink-0' - > - Accept - - void handleDecline(inv)} - aria-label={`Decline invitation to ${invitationLabel(inv)}`} - className='flex-shrink-0' - > - Decline - + invitations.map((inv) => ( +
+
+

{invitationLabel(inv)}

+

+ {invitationSubLabel(inv)} +

- ) - }) + void handleAccept(inv)} + className='flex-shrink-0' + > + Accept + + void handleDecline(inv)} + aria-label={`Decline invitation to ${invitationLabel(inv)}`} + className='flex-shrink-0' + > + Decline + +
+ )) )} [0]['workspaces'] +/** Pinning reorders the list; these assertions are about the highlight, not the order. */ +const NO_PINS: ReadonlySet = new Set() + let container: HTMLDivElement let root: Root @@ -86,6 +91,8 @@ function render() { activeWorkspace={{ name: "Emir's Workspace" }} workspaceId='ws-emir' workspaces={WORKSPACES} + pinnedWorkspaceIds={NO_PINS} + onToggleWorkspacePin={() => {}} isWorkspacesLoading={false} isCreatingWorkspace={false} isWorkspaceMenuOpen diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 6c76d2c69a5..45226c1d815 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -19,7 +19,7 @@ import { Skeleton, Tooltip, } from '@sim/emcn' -import { MoreHorizontal, PanelLeft, Search } from '@sim/emcn/icons' +import { MoreHorizontal, PanelLeft, Pin, Search } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { isBillingEnabled } from '@/lib/core/config/env-flags' @@ -42,8 +42,17 @@ import { SIDEBAR_WIDTH } from '@/stores/constants' const logger = createLogger('WorkspaceHeader') -/** Show the search input once the workspace list exceeds this count. */ -const WORKSPACE_SEARCH_THRESHOLD = 3 +/** + * Show the search input once the workspace list reaches this count, and size the + * list viewport to exactly this many rows — so the sixth workspace is the one that + * both fills the viewport and brings in search. + * + * The viewport's `max-h-[190px]` is derived from it: 6 rows at `chipGeometryClass`'s + * 30px plus the 2px `gap-0.5` between them (6 * 30 + 5 * 2). Tailwind arbitrary + * values must be statically analyzable, so the arithmetic cannot live in the class — + * change the two together. + */ +const WORKSPACE_SEARCH_THRESHOLD = 6 /** * Derives the single-letter avatar initial for a workspace, ignoring the word @@ -80,8 +89,12 @@ interface WorkspaceHeaderProps { activeWorkspace?: { name: string } | null /** Current workspace ID */ workspaceId: string - /** List of available workspaces */ + /** List of available workspaces, already ordered pinned-first */ workspaces: Workspace[] + /** Ids of workspaces the viewer pinned to the top of the switcher */ + pinnedWorkspaceIds: ReadonlySet + /** Callback to toggle a workspace's pinned state */ + onToggleWorkspacePin: (workspaceId: string) => void /** Server-derived workspace creation policy for the current user context */ workspaceCreationPolicy?: WorkspaceCreationPolicy | null /** Whether workspaces are loading */ @@ -123,6 +136,8 @@ function WorkspaceHeaderImpl({ activeWorkspace, workspaceId, workspaces, + pinnedWorkspaceIds, + onToggleWorkspacePin, workspaceCreationPolicy, isWorkspacesLoading, isCreatingWorkspace, @@ -155,8 +170,18 @@ function WorkspaceHeaderImpl({ const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [menuOpenWorkspaceId, setMenuOpenWorkspaceId] = useState(null) const contextMenuRef = useRef(null) + /** + * The row a context-menu action targets. Set alongside `menuOpenWorkspaceId` in + * {@link openContextMenuAt}, but only that state re-renders — so anything the menu + * *renders* must read the state, and only handlers may read this ref. + */ const capturedWorkspaceRef = useRef(null) - const isRenamingRef = useRef(false) + /** + * Set by context-menu actions whose result is only visible in the still-open + * switcher — renaming (the inline input lives there) and pinning (the row moves + * to the pinned group). + */ + const keepWorkspaceMenuOpenRef = useRef(false) const isContextMenuOpeningRef = useRef(false) const contextMenuClosedRef = useRef(true) const hasInputFocusedRef = useRef(false) @@ -180,7 +205,7 @@ function WorkspaceHeaderImpl({ */ const [isKeyboardNav, setIsKeyboardNav] = useState(false) - const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD + const showSearch = workspaces.length >= WORKSPACE_SEARCH_THRESHOLD const searchQuery = workspaceSearch.trim().toLowerCase() const filteredWorkspaces = showSearch && searchQuery @@ -322,10 +347,10 @@ function WorkspaceHeaderImpl({ setMenuOpenWorkspaceId(null) const isOpeningAnother = isContextMenuOpeningRef.current isContextMenuOpeningRef.current = false - if (!isRenamingRef.current && !isOpeningAnother) { + if (!keepWorkspaceMenuOpenRef.current && !isOpeningAnother) { setIsWorkspaceMenuOpen(false) } - isRenamingRef.current = false + keepWorkspaceMenuOpenRef.current = false } /** @@ -334,7 +359,7 @@ function WorkspaceHeaderImpl({ const handleRenameAction = () => { if (!capturedWorkspaceRef.current) return - isRenamingRef.current = true + keepWorkspaceMenuOpenRef.current = true hasInputFocusedRef.current = false setEditingWorkspaceId(capturedWorkspaceRef.current.id) setEditingName(capturedWorkspaceRef.current.name) @@ -369,6 +394,13 @@ function WorkspaceHeaderImpl({ } } + const handleTogglePinAction = () => { + const target = capturedWorkspaceRef.current + if (!target) return + keepWorkspaceMenuOpenRef.current = true + onToggleWorkspacePin(target.id) + } + const handleUploadLogoAction = () => { if (!capturedWorkspaceRef.current) return onUploadLogo(capturedWorkspaceRef.current.id) @@ -509,7 +541,11 @@ function WorkspaceHeaderImpl({ align='start' side={isCollapsed ? 'right' : 'bottom'} sideOffset={isCollapsed ? 16 : 8} - className='flex max-h-none flex-col overflow-hidden' + /* Overrides the 240px default cap so the six-row list is not clipped, but + still bounded by the space Radix measured — at six rows the menu is tall + enough that a short viewport would otherwise push the footer actions off + screen with nothing able to scroll to them. */ + className='flex max-h-[var(--radix-dropdown-menu-content-available-height,400px)] flex-col overflow-y-auto' style={{ width: `${SIDEBAR_WIDTH.DEFAULT}px`, maxWidth: 'calc(100vw - 24px)', @@ -565,7 +601,7 @@ function WorkspaceHeaderImpl({ )}
{filteredWorkspaces.length === 0 && workspaceSearch && (
@@ -711,6 +747,14 @@ function WorkspaceHeaderImpl({ {workspace.name} + {pinnedWorkspaceIds.has(workspace.id) && ( + + )}