Skip to content

Commit 1c5393a

Browse files
authored
feat(workspaces): pin workspaces and widen the switcher to six rows (#6397)
* 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. * fix(workspaces): serialize pin writes so a rapid toggle cannot be undone Each write carries the whole pin list, so two overlapping requests that the network delivered out of order left the earlier click as the stored state. Chain them instead, and hold reconciliation until the last queued write settles — refetching between two writes rendered the server's intermediate state and bounced the row out of the pinned group and back. * refactor(workspaces): store workspace pins in pinned_item, not user settings Workspace pins were a jsonb array on the settings row, replaced wholesale on every toggle. That shape is what forced the write serialization in 53ee94f: two overlapping toggles each sent the entire list, so the one that landed last won regardless of which the user clicked last. pinned_item is the canonical pinning table and its resource_type is plain text precisely so kinds can be added without a migration, so `workspace` joins it as a sixth kind. A pin is now one row: pinning inserts, unpinning deletes, and two toggles touch different rows and cannot overwrite each other. The serialization, the outstanding-write counter, the settings column, and its migration all go away, and deleting a workspace now cascades its pins. Reads stay on the /api/workspaces payload — the switcher needs the pins *of* every workspace, not the pins *inside* one — so the sidebar prefetch still hydrates them and pinned-first ordering is correct on first paint. * fix(workspaces): serialize same-workspace pin toggles and tolerate replays Splitting pins into rows removed the lost-update race between *different* workspaces but not the one on a single row: pin then unpin the same workspace and the DELETE could overtake its INSERT, delete nothing, and leave the workspace pinned. A mutation scope serializes them; TanStack runs onMutate before the scope gate, so the optimistic update is still immediate. Both duplicate-click replays now resolve to their end state rather than erroring — a repeat pin answers 409, a repeat unpin 404, and each means the row is already how the caller wants it. Rollback undoes its own toggle instead of restoring a snapshot, so a sibling toggle's optimistic state survives. Also: cap the switcher to the height Radix measured, since six rows can push the footer actions off a short viewport with nothing able to scroll to them; drop a dead pinned-item invalidation and a redundant ref; return the pin set from the hook to match usePinnedIds; and exclude workspace pins from the unscoped pinned-items listing, where they would read as a resource inside themselves. * fix(workspaces): hold pin reconciliation until nothing is still queued The mutation scope serializes the writes, so an earlier toggle settles while a later one is still waiting its turn. Invalidating there refetched the server's intermediate state and bounced the row out of the pinned group and back before the last write had even left the client. * fix(workspaces): count outstanding pin toggles off the mutation cache `hooks/queries/workspace.ts` has no 'use client' directive because server code imports `workspaceKeys` during SSR, so the `useRef` counter added in 3dc924d broke the production build — caught by CI, not by typecheck or tests. `isMutating` answers the same question without a hook: `onSettled` runs before the mutation leaves `pending`, so it counts itself, and anything above one means a later toggle is still queued behind the scope.
1 parent d317607 commit 1c5393a

22 files changed

Lines changed: 570 additions & 517 deletions

File tree

apps/sim/app/api/invitations/[id]/route.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,16 @@ export const GET = withRouteHandler(
6363
}
6464

6565
/**
66-
* Disclosure-only: a preview failure must never block viewing or
67-
* accepting the invitation itself — but it also must not read as
68-
* "nothing moves", so failures are flagged for the client to show a
69-
* generic migration notice. Expired-but-still-pending rows get no
70-
* preview — acceptance deterministically rejects them.
66+
* Supplies the disclosure token acceptance is checked against, so a preview
67+
* failure must never block viewing or accepting the invitation itself — the
68+
* accept path simply runs without the guard. Expired-but-still-pending rows
69+
* get no preview; acceptance deterministically rejects them.
7170
*/
7271
let joinPreview = null
73-
let joinPreviewUnavailable = false
7472
if (isInvitee && inv.status === 'pending' && !isInvitationExpired(inv)) {
7573
try {
7674
joinPreview = await getInvitationJoinPreview(session.user.id, inv)
7775
} catch (previewError) {
78-
joinPreviewUnavailable = true
7976
logger.warn('Failed to compute invitation join preview', {
8077
invitationId: id,
8178
error: previewError,
@@ -85,7 +82,6 @@ export const GET = withRouteHandler(
8582

8683
return NextResponse.json({
8784
joinPreview,
88-
joinPreviewUnavailable,
8985
invitation: {
9086
id: inv.id,
9187
kind: inv.kind,

apps/sim/app/api/pinned-items/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db, pinnedItem } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getPostgresErrorCode } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
5-
import { and, eq } from 'drizzle-orm'
5+
import { and, eq, ne } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import {
88
createPinnedItemContract,
@@ -59,7 +59,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5959
and(
6060
eq(pinnedItem.userId, session.user.id),
6161
eq(pinnedItem.workspaceId, workspaceId),
62-
resourceType ? eq(pinnedItem.resourceType, resourceType) : undefined
62+
/**
63+
* A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise
64+
* appear in this workspace's unscoped listing as a resource *inside* itself.
65+
* It is read from the workspace-list payload instead, so it is excluded here
66+
* rather than left for a future unscoped caller to mistake for a real resource.
67+
*/
68+
resourceType
69+
? eq(pinnedItem.resourceType, resourceType)
70+
: ne(pinnedItem.resourceType, 'workspace')
6371
)
6472
)
6573

apps/sim/app/api/workspaces/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,16 @@ export const GET = withRouteHandler(async (request: Request) => {
5353
activeOrganizationId,
5454
scope,
5555
})
56-
const { lastActiveWorkspaceId, creationPolicy } = payload
56+
const { lastActiveWorkspaceId, pinnedWorkspaceIds, creationPolicy } = payload
5757

5858
if (scope === 'active' && payload.workspaces.length === 0) {
5959
if (!creationPolicy.canCreate) {
60-
return NextResponse.json({ workspaces: [], lastActiveWorkspaceId, creationPolicy })
60+
return NextResponse.json({
61+
workspaces: [],
62+
lastActiveWorkspaceId,
63+
pinnedWorkspaceIds,
64+
creationPolicy,
65+
})
6166
}
6267

6368
let defaultWorkspace: Awaited<ReturnType<typeof createDefaultWorkspace>>
@@ -100,6 +105,7 @@ export const GET = withRouteHandler(async (request: Request) => {
100105
return NextResponse.json({
101106
workspaces: [defaultWorkspace],
102107
lastActiveWorkspaceId,
108+
pinnedWorkspaceIds,
103109
creationPolicy: refreshedCreationPolicy,
104110
})
105111
}

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 4 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useEffect, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
65
import { getErrorMessage } from '@sim/utils/errors'
76
import { formatQuotedNameList } from '@sim/utils/string'
87
import { useQueryClient } from '@tanstack/react-query'
@@ -11,11 +10,6 @@ import { ApiClientError } from '@/lib/api/client/errors'
1110
import { requestJson } from '@/lib/api/client/request'
1211
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
1312
import { client, useSession } from '@/lib/auth/auth-client'
14-
import {
15-
buildMembershipNotice,
16-
buildWorkspaceMigrationNotice,
17-
MAX_LISTED_WORKSPACE_NAMES,
18-
} from '@/lib/invitations/disclosure-copy'
1913
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
2014
import { useInvitationDetails } from '@/hooks/queries/invitations'
2115
import { organizationKeys } from '@/hooks/queries/organization'
@@ -25,6 +19,9 @@ import { workspaceKeys } from '@/hooks/queries/workspace'
2519

2620
const logger = createLogger('InviteById')
2721

22+
/** Workspace names listed in the invitation title before collapsing into an "and N more" tail. */
23+
const MAX_LISTED_WORKSPACE_NAMES = 3
24+
2825
function runBestEffortCacheRefresh(cache: string, refresh: () => Promise<unknown>): void {
2926
void Promise.resolve()
3027
.then(refresh)
@@ -233,7 +230,6 @@ export default function Invite() {
233230
})
234231
const invitation = invitationQuery.data?.invitation ?? null
235232
const joinPreview = invitationQuery.data?.joinPreview ?? null
236-
const joinPreviewUnavailable = invitationQuery.data?.joinPreviewUnavailable === true
237233
const isLoading = Boolean(session?.user) && invitationQuery.isPending
238234

239235
const fetchError = invitationQuery.error
@@ -491,53 +487,13 @@ export default function Invite() {
491487
}
492488

493489
const isOrg = invitation?.kind === 'organization'
494-
/**
495-
* Prefer the preview's organization (the one acceptance will really join)
496-
* over the invitation's stamped name — a granted workspace may have moved
497-
* organizations since the invite was sent.
498-
*/
499-
const organizationLabel =
500-
joinPreview?.organizationName || invitation?.organizationName || 'the organization'
501-
/**
502-
* When the server could not compute the preview, fall back to a generic
503-
* migration notice for membership invites — a missing preview must never
504-
* read as "nothing moves".
505-
*/
506-
const migrationNotice = buildWorkspaceMigrationNotice({
507-
joinPreview,
508-
joinPreviewUnavailable,
509-
membershipIntent: invitation?.membershipIntent,
510-
organizationLabel,
511-
})
512-
/**
513-
* Only disclosed when the invitation actually carries organization standing —
514-
* a personal-workspace invite has no seat or membership to explain.
515-
*/
516-
const membershipNotice = buildMembershipNotice({
517-
joinPreview,
518-
membershipIntent: invitation?.membershipIntent,
519-
isOrganizationAdminRole: Boolean(invitation?.role && isOrgAdminRole(invitation.role)),
520-
organizationLabel,
521-
/**
522-
* A personal-workspace invite has no organization id and no organization
523-
* name yet — acceptance creates one by converting the billed owner's Pro to
524-
* Team — so a `will-join` outcome is the authoritative signal that a
525-
* membership and seat are involved. Gating on the ids alone silenced the
526-
* disclosure for exactly the case that creates the membership.
527-
*/
528-
isOrganizationScoped: Boolean(
529-
invitation?.organizationId ||
530-
joinPreview?.organizationName ||
531-
joinPreview?.outcome === 'will-join'
532-
),
533-
})
534490

535491
return (
536492
<InviteLayout>
537493
<InviteStatusCard
538494
type='invitation'
539495
title={isOrg ? 'Organization Invitation' : 'Workspace Invitation'}
540-
description={`You've been invited to join ${displayName}.${membershipNotice}${migrationNotice}`}
496+
description={`You've been invited to join ${displayName}.`}
541497
icon={isOrg ? 'users' : 'mail'}
542498
actions={[
543499
{

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx

Lines changed: 25 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,9 @@
22

33
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast } from '@sim/emcn'
44
import { createLogger } from '@sim/logger'
5-
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
65
import { getErrorMessage } from '@sim/utils/errors'
76
import { useRouter } from 'next/navigation'
87
import type { MyInvitation } from '@/lib/api/contracts/invitations'
9-
import {
10-
buildMembershipNotice,
11-
buildWorkspaceMigrationNotice,
12-
} from '@/lib/invitations/disclosure-copy'
138
import { getInvitationErrorMessage } from '@/lib/invitations/error-messages'
149
import {
1510
useAcceptMyInvitation,
@@ -43,37 +38,6 @@ function invitationSubLabel(inv: MyInvitation): string {
4338
return detail ? `${invitedBy} · ${detail}` : invitedBy
4439
}
4540

46-
/**
47-
* What accepting will do to the invitee's own account: the seat/membership
48-
* consequence and any workspaces that will move into the organization. Built
49-
* from the same copy as the emailed `/invite` page so the two accept surfaces
50-
* can never disclose different outcomes.
51-
*/
52-
function invitationDisclosure(inv: MyInvitation): string {
53-
const organizationLabel =
54-
inv.joinPreview?.organizationName ?? inv.organizationName ?? 'the organization'
55-
const membership = buildMembershipNotice({
56-
joinPreview: inv.joinPreview,
57-
membershipIntent: inv.membershipIntent,
58-
isOrganizationAdminRole: isOrgAdminRole(inv.role),
59-
organizationLabel,
60-
/** A `will-join` outcome also covers a personal-workspace invite, which has
61-
* no organization id or name until acceptance creates one. */
62-
isOrganizationScoped: Boolean(
63-
inv.organizationId ||
64-
inv.joinPreview?.organizationName ||
65-
inv.joinPreview?.outcome === 'will-join'
66-
),
67-
})
68-
const migration = buildWorkspaceMigrationNotice({
69-
joinPreview: inv.joinPreview,
70-
joinPreviewUnavailable: inv.joinPreview === null,
71-
membershipIntent: inv.membershipIntent,
72-
organizationLabel,
73-
})
74-
return `${membership}${migration}`.trim()
75-
}
76-
7741
interface ViewInvitationsModalProps {
7842
open: boolean
7943
onOpenChange: (open: boolean) => void
@@ -133,38 +97,32 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
13397
{!invitations || invitations.length === 0 ? (
13498
<p className='px-2 text-[var(--text-muted)] text-sm'>No pending invitations.</p>
13599
) : (
136-
invitations.map((inv) => {
137-
const disclosure = invitationDisclosure(inv)
138-
return (
139-
<div key={inv.id} className='flex items-start gap-2 px-2'>
140-
<div className='min-w-0 flex-1'>
141-
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
142-
<p className='truncate text-[var(--text-muted)] text-caption'>
143-
{invitationSubLabel(inv)}
144-
</p>
145-
{disclosure ? (
146-
<p className='mt-1 text-[var(--text-muted)] text-caption'>{disclosure}</p>
147-
) : null}
148-
</div>
149-
<Chip
150-
variant='primary'
151-
disabled={isBusy}
152-
onClick={() => void handleAccept(inv)}
153-
className='flex-shrink-0'
154-
>
155-
Accept
156-
</Chip>
157-
<Chip
158-
disabled={isBusy}
159-
onClick={() => void handleDecline(inv)}
160-
aria-label={`Decline invitation to ${invitationLabel(inv)}`}
161-
className='flex-shrink-0'
162-
>
163-
Decline
164-
</Chip>
100+
invitations.map((inv) => (
101+
<div key={inv.id} className='flex items-center gap-2 px-2'>
102+
<div className='min-w-0 flex-1'>
103+
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
104+
<p className='truncate text-[var(--text-muted)] text-caption'>
105+
{invitationSubLabel(inv)}
106+
</p>
165107
</div>
166-
)
167-
})
108+
<Chip
109+
variant='primary'
110+
disabled={isBusy}
111+
onClick={() => void handleAccept(inv)}
112+
className='flex-shrink-0'
113+
>
114+
Accept
115+
</Chip>
116+
<Chip
117+
disabled={isBusy}
118+
onClick={() => void handleDecline(inv)}
119+
aria-label={`Decline invitation to ${invitationLabel(inv)}`}
120+
className='flex-shrink-0'
121+
>
122+
Decline
123+
</Chip>
124+
</div>
125+
))
168126
)}
169127
</ChipModalBody>
170128
<ChipModalFooter

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,22 @@ import { WorkspaceHeader } from '@/app/workspace/[workspaceId]/w/components/side
6161
const ACTIVE_BG = 'bg-[var(--surface-active)]'
6262

6363
/**
64-
* Above `WORKSPACE_SEARCH_THRESHOLD` (3), so the searchable/keyboard list renders.
64+
* At `WORKSPACE_SEARCH_THRESHOLD` (6), so the searchable/keyboard list renders.
6565
* The current workspace is deliberately NOT first: the highlight is seeded to row 0 on
6666
* open, so a current workspace sitting at row 0 would mask the double-mark this guards.
6767
*/
6868
const WORKSPACES = [
6969
{ id: 'ws-rvt', name: 'RVT' },
7070
{ id: 'ws-emir', name: "Emir's Workspace" },
7171
{ id: 'ws-acme', name: 'Acme' },
72+
{ id: 'ws-initech', name: 'Initech' },
73+
{ id: 'ws-umbrella', name: 'Umbrella' },
7274
{ id: 'ws-globex', name: 'Globex' },
7375
] as unknown as Parameters<typeof WorkspaceHeader>[0]['workspaces']
7476

77+
/** Pinning reorders the list; these assertions are about the highlight, not the order. */
78+
const NO_PINS: ReadonlySet<string> = new Set()
79+
7580
let container: HTMLDivElement
7681
let root: Root
7782

@@ -86,6 +91,8 @@ function render() {
8691
activeWorkspace={{ name: "Emir's Workspace" }}
8792
workspaceId='ws-emir'
8893
workspaces={WORKSPACES}
94+
pinnedWorkspaceIds={NO_PINS}
95+
onToggleWorkspacePin={() => {}}
8996
isWorkspacesLoading={false}
9097
isCreatingWorkspace={false}
9198
isWorkspaceMenuOpen

0 commit comments

Comments
 (0)