Skip to content

feat(workspaces): pin workspaces and widen the switcher to six rows - #6397

Merged
waleedlatif1 merged 6 commits into
stagingfrom
worktree-workspace-dropdown-pinning
Aug 8, 2026
Merged

feat(workspaces): pin workspaces and widen the switcher to six rows#6397
waleedlatif1 merged 6 commits into
stagingfrom
worktree-workspace-dropdown-pinning

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Workspace switcher now shows up to 6 workspaces instead of 3, and keeps the search input from 6 onward so it appears exactly when the list fills
  • Pin/unpin workspaces from the existing row context menu; pinned ones float to the top, recency still orders within each group
  • Pins are per-user and global, so they live on settings.pinned_workspace_ids rather than pinned_item (every row there is scoped 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 — no extra request, no flash
  • Removed the seat/workspace-migration disclosure copy from both invitation accept surfaces, plus the now-dead joinPreviewUnavailable field it fed. Accept-time disclosure tokens are unchanged, so the server still verifies the outcome hasn't shifted since page load

Type of Change

  • New feature

Testing

Migration verified by running the full chain against a scratch database (285/285 applied, column lands as jsonb NOT NULL DEFAULT '[]') and round-tripping a value. drizzle-kit generate reports no remaining schema changes. Full suite green (20431 tests). Not yet browser-tested.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

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.
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 8, 2026 3:01am

Request Review

@cursor

cursor Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Invitation pre-accept disclosure is reduced in the UI while server accept guards remain; workspace pinning adds new persisted state and list ordering that must stay consistent across optimistic updates and refetches.

Overview
Adds workspace pinning in the switcher: pin/unpin from the row context menu, pinned workspaces float to the top (recency still orders within pinned vs unpinned groups), with a pin icon on pinned rows. Pin state is stored as pinned_item rows with resourceType: 'workspace'; ids are returned on GET /api/workspaces as pinnedWorkspaceIds so the list can sort pinned-first without a separate fetch. Unscoped GET /api/pinned-items now excludes workspace pins so a workspace pin does not show up as a resource inside itself.

The switcher list grows from 3 to 6 visible rows (max-h 94px → 190px); search appears when there are ≥6 workspaces (was >3). Dropdown max height uses Radix available height so footer actions stay reachable on short viewports.

Invitation accept UI no longer shows membership/seat or workspace-migration disclosure text on the /invite page or the pending-invitations modal; joinPreviewUnavailable is removed from the invitation details API. Accept still sends disclosedWorkspaceIds / disclosedOutcome when a preview exists.

Client pin toggles use useToggleWorkspacePin with optimistic updates, serialized mutations per workspace, 409/404 treated as success, and deferred list invalidation until the queue drains. Tests cover pin mutation behavior; workspace-header tests updated for six-row search threshold and new props.

Reviewed by Cursor Bugbot for commit 9c2759f. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds per-user workspace pinning, pinned-first switcher ordering, and a six-row searchable workspace menu while removing invitation disclosure copy.

  • Persists workspace pins through the existing pinned-item routes and includes their IDs in workspace-list responses.
  • Applies immediate optimistic pin updates while serializing writes and deferring reconciliation until all queued toggles settle.
  • Expands the workspace switcher viewport and search threshold from three to six entries.
  • Removes seat and workspace-migration notices from invitation acceptance surfaces.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported concurrent-write and intermediate-reconciliation failures are addressed by serialized mutations, deferred final invalidation, and regression coverage, so no blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/hooks/queries/workspace.ts Adds workspace-pin query selection, optimistic cache updates, serialized writes, rollback behavior, and deferred final reconciliation.
apps/sim/hooks/queries/workspace.test.tsx Covers pin and unpin requests, same-workspace serialization, deferred reconciliation, idempotent responses, and rollback behavior.
apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts Integrates pinned workspace state into pinned-first, recency-preserving switcher ordering.
apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx Adds pin controls and indicators while expanding the searchable workspace menu to six rows.
apps/sim/lib/workspaces/list.ts Adds the viewer’s pinned workspace IDs to workspace-list payload construction.
packages/db/schema.ts Extends the pinned-resource type to support workspace pins.
apps/sim/app/invite/[id]/invite.tsx Removes membership and workspace-migration disclosure text from the invitation page.
apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx Removes invitation disclosure text from the pending-invitations modal.

Sequence Diagram

sequenceDiagram
  participant U as User
  participant UI as Workspace Switcher
  participant Q as Query Cache
  participant API as Pinned Item API
  participant DB as Database
  U->>UI: Toggle workspace pin
  UI->>Q: Optimistically update pinnedWorkspaceIds
  UI->>API: Send serialized pin/unpin request
  API->>DB: Insert or delete workspace pin
  DB-->>API: Completed
  API-->>UI: Mutation settled
  alt More toggles queued
    UI->>UI: Defer reconciliation
  else Queue empty
    UI->>Q: Invalidate workspace lists
  end
Loading

Reviews (6): Last reviewed commit: "fix(workspaces): count outstanding pin t..." | Re-trigger Greptile

Comment thread apps/sim/hooks/queries/workspace.ts Outdated
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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/queries/workspace.ts Outdated
…ettings

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.
…plays

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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Heads up for reviewers: the storage design changed after the first round, so the resolved thread above describes an approach that is no longer in the diff.

What changed. Pins were a jsonb array on settings, replaced wholesale on every toggle — which is what forced the write serialization I described in that thread. Two of my stated reasons for not using pinned_item were simply wrong: workspace.archivedAt does exist, and the unique index is (user_id, resource_type, resource_id), which excludes workspace_id and fits a workspace pin exactly.

So workspace is now a sixth pinnedResourceType. A pin is one row — insert to pin, delete to unpin. resource_type is plain text precisely so kinds can grow, so this needs no migration at all; the settings column and its migration are gone, and deleting a workspace now cascades its pins.

Reads still come off the /api/workspaces payload rather than GET /api/pinned-items, because the switcher needs the pins of every workspace, not the pins inside one. That also keeps the sidebar prefetch hydrating them, so pinned-first ordering is right on first paint instead of re-sorting after hydration.

Also fixed since round one, from a follow-up audit:

  • Splitting into rows removed the cross-workspace race but not the same-row one — pin then unpin one workspace and the DELETE could overtake its INSERT, delete nothing, and leave it pinned. A mutation scope serializes those; onMutate runs before the scope gate, so optimistic UI stays instant.
  • Unpin was not idempotent (DELETE answers 404 on zero rows); both replay codes are now treated as the requested end state.
  • Six rows made the menu tall enough to push the footer actions off a short viewport, since it overrode emcn's cap with max-h-none overflow-hidden. Now clamped to Radix's measured available height, matching context-menu.tsx.
  • Workspace pins are excluded from the unscoped pinned-items listing, where they would otherwise read as a resource inside themselves.

Full suite green (20775 tests). Not browser-verified yet — the short-viewport clamp in particular is reasoned, not observed.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/hooks/queries/workspace.ts
Comment thread apps/sim/hooks/queries/workspace.ts
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.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3dc924d. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 839a6f3. Configure here.

`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.
@waleedlatif1
waleedlatif1 force-pushed the worktree-workspace-dropdown-pinning branch from 839a6f3 to 9c2759f Compare August 8, 2026 02:55
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 9c2759f. Configure here.

@waleedlatif1
waleedlatif1 merged commit 1c5393a into staging Aug 8, 2026
29 of 30 checks passed
@waleedlatif1
waleedlatif1 deleted the worktree-workspace-dropdown-pinning branch August 8, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant