From f074872743da2b94c3a477ed92a4b8b3277b9383 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 17:58:24 -0700 Subject: [PATCH 1/6] feat(workspaces): pin workspaces and widen the switcher to six rows Show up to six workspaces in the switcher instead of three, keeping the search input from six onward so it appears exactly when the list fills. Pin workspaces to the top of the switcher via the existing row context menu. Pins are per-user and global, so they live on the user's settings row rather than in `pinned_item`, which scopes every row to one workspace. They ride along on the /api/workspaces payload the switcher already loads, so the server prefetch hydrates them and pinned-first ordering never re-sorts after hydration. Drop the seat/workspace-migration disclosure copy from both invitation accept surfaces. The accept-time disclosure tokens are unchanged, so the server still verifies the outcome hasn't shifted since the page loaded. --- apps/sim/app/api/invitations/[id]/route.ts | 12 +- apps/sim/app/api/workspaces/route.ts | 10 +- apps/sim/app/invite/[id]/invite.tsx | 52 +- .../view-invitations-modal.tsx | 92 +- .../workspace-header.test.tsx | 9 +- .../workspace-header/workspace-header.tsx | 69 +- .../hooks/use-workspace-management.test.tsx | 3 + .../sidebar/hooks/use-workspace-management.ts | 49 +- .../w/components/sidebar/sidebar.tsx | 4 + .../workspace-forking/hooks/workspace-fork.ts | 7 +- apps/sim/hooks/queries/workspace.ts | 63 +- apps/sim/lib/api/contracts/invitations.ts | 8 - apps/sim/lib/api/contracts/user.ts | 10 + apps/sim/lib/api/contracts/workspaces.ts | 7 + .../lib/invitations/disclosure-copy.test.ts | 233 - apps/sim/lib/invitations/disclosure-copy.ts | 117 - apps/sim/lib/users/queries.ts | 13 +- apps/sim/lib/workspaces/list.ts | 20 +- packages/db/migrations/0285_cuddly_freak.sql | 1 + .../db/migrations/meta/0285_snapshot.json | 18797 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 10 + 22 files changed, 19082 insertions(+), 511 deletions(-) delete mode 100644 apps/sim/lib/invitations/disclosure-copy.test.ts delete mode 100644 apps/sim/lib/invitations/disclosure-copy.ts create mode 100644 packages/db/migrations/0285_cuddly_freak.sql create mode 100644 packages/db/migrations/meta/0285_snapshot.json 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/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..6c6567924a6 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, @@ -156,7 +171,12 @@ function WorkspaceHeaderImpl({ const [menuOpenWorkspaceId, setMenuOpenWorkspaceId] = useState(null) const contextMenuRef = useRef(null) 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 +200,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 +342,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 +354,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 +389,17 @@ function WorkspaceHeaderImpl({ } } + /** + * Pinning leaves the switcher open: the row jumps to the pinned group, and + * closing the menu would hide the only feedback that the action landed. + */ + 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) @@ -565,7 +596,7 @@ function WorkspaceHeaderImpl({ )}
{filteredWorkspaces.length === 0 && workspaceSearch && (
@@ -711,6 +742,16 @@ function WorkspaceHeaderImpl({ {workspace.name} + {pinnedWorkspaceIds.has(workspace.id) && ( + /* `Pin` hardcodes `aria-hidden` ahead of its prop spread, + so un-hiding it is what makes the label announce. */ + + )}