Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.',
'1 year',
],
[
'workspace_recency',

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The policy's own text commits to revising the "Last updated" date when the cookies we set change: "We update this policy when the cookies we set change, and we revise the 'Last updated' date above whenever we do." This PR adds a new workspace_recency cookie to the inventory (it is genuinely set, with max-age=31536000 in apps/sim/lib/core/utils/browser-storage.ts, so the "1 year" retention is accurate) without bumping lastUpdated, which still reads 'September 17, 2026'. Update lastUpdated to the publication date so the policy stays consistent with its own freshness statement.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx, line 166:

<comment>The policy's own text commits to revising the "Last updated" date when the cookies we set change: "We update this policy when the cookies we set change, and we revise the 'Last updated' date above whenever we do." This PR adds a new `workspace_recency` cookie to the inventory (it is genuinely set, with `max-age=31536000` in `apps/sim/lib/core/utils/browser-storage.ts`, so the "1 year" retention is accurate) without bumping `lastUpdated`, which still reads 'September 17, 2026'. Update `lastUpdated` to the publication date so the policy stays consistent with its own freshness statement.</comment>

<file context>
@@ -162,6 +162,12 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
             '1 year',
           ],
+          [
+            'workspace_recency',
+            'Sim',
+            'Remembers which workspaces you opened most recently, so the sidebar lists them in that order without reshuffling on load.',
</file context>
Fix with cubic

'Sim',
'Remembers which workspaces you opened most recently, so the sidebar lists them in that order without reshuffling on load.',
'1 year',
],
[
'__cf_bm',
'Cloudflare',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { act } from 'react'
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider'
import { STORAGE_KEYS, WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie'

const { mockUseWorkspacesQuery, pins } = vi.hoisted(() => ({
pins: { current: new Set<string>() },
Expand Down Expand Up @@ -64,20 +66,31 @@ function workspaceIds() {
}

describe('useOrganizationWorkspaces', () => {
it('hydrates the prefetched order before applying visit history without changing the query cache', async () => {
it('renders the visit order on the server so hydration does not reshuffle rows', async () => {
localStorage.setItem(
STORAGE_KEYS.WORKSPACE_RECENCY,
JSON.stringify({ oldest: 100, older: 200, 'other-org': 300 })
)
container.innerHTML = renderToString(<Harness />)
expect(workspaceIds()).toEqual(['newest', 'older', 'oldest'])
const recentIds = ['other-org', 'older', 'oldest']
const tree = (
<WorkspaceRecencyProvider recentWorkspaceIds={recentIds}>
<Harness />
</WorkspaceRecencyProvider>
)
container.innerHTML = renderToString(tree)
expect(workspaceIds()).toEqual(['older', 'oldest', 'newest'])

const onRecoverableError = vi.fn()
const observed: string[][] = []
const observer = new MutationObserver(() => observed.push(workspaceIds()))
observer.observe(container, { childList: true, subtree: true, characterData: true })
await act(async () => {
root = hydrateRoot(container, <Harness />, { onRecoverableError })
root = hydrateRoot(container, tree, { onRecoverableError })
})
observer.disconnect()

expect(onRecoverableError).not.toHaveBeenCalled()
expect(observed).toEqual([])
expect(workspaceIds()).toEqual(['older', 'oldest', 'newest'])
expect(mockUseWorkspacesQuery().data.map(({ id }: { id: string }) => id)).toEqual([
'newest',
Expand All @@ -87,6 +100,15 @@ describe('useOrganizationWorkspaces', () => {
])
})

it('mirrors visits into the recency cookie', async () => {
await act(async () => {
root = createRoot(container)
root.render(<Harness />)
})
await act(async () => WorkspaceRecencyStorage.touch('older'))
expect(document.cookie).toContain(`${WORKSPACE_RECENCY_COOKIE}=older`)
})

it('preserves creation-date order when the browser has no visit history', async () => {
await act(async () => {
root = createRoot(container)
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { WorkspaceRecencyProvider } from '@/components/workspaces/workspace-recency-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
import { parseWorkspaceRecency, WORKSPACE_RECENCY_COOKIE } from '@/lib/workspaces/recency-cookie'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
Expand Down Expand Up @@ -66,6 +68,7 @@ export default async function OrganizationLayout({
isPlanModeEnabled(),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
const recentWorkspaceIds = parseWorkspaceRecency(cookieStore.get(WORKSPACE_RECENCY_COOKIE)?.value)

return (
<HydrationBoundary state={dehydrate(queryClient)}>
Expand All @@ -81,12 +84,14 @@ export default async function OrganizationLayout({
<div className='workspace-root flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<SessionExpired />
<WorkspaceChrome
sidebar={<OrganizationSidebar />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
<WorkspaceRecencyProvider recentWorkspaceIds={recentWorkspaceIds}>

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The provider makes SSR use only the cookie’s 20 IDs, but useWorkspaceOrder switches to the uncapped localStorage map after hydration. Keep the client handoff limited to the same 20 IDs, or otherwise preserve the cookie order, to prevent older tracked and untracked workspaces from visibly reshuffling again.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/app/o/[organizationId]/layout.tsx, line 87:

<comment>The provider makes SSR use only the cookie’s 20 IDs, but `useWorkspaceOrder` switches to the uncapped localStorage map after hydration. Keep the client handoff limited to the same 20 IDs, or otherwise preserve the cookie order, to prevent older tracked and untracked workspaces from visibly reshuffling again.</comment>

<file context>
@@ -81,12 +84,14 @@ export default async function OrganizationLayout({
-              >
-                {children}
-              </WorkspaceChrome>
+              <WorkspaceRecencyProvider recentWorkspaceIds={recentWorkspaceIds}>
+                <WorkspaceChrome
+                  sidebar={<OrganizationSidebar />}
</file context>
Fix with cubic

<WorkspaceChrome
sidebar={<OrganizationSidebar />}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
</WorkspaceRecencyProvider>
</div>
</GlobalCommandsProvider>
</OrganizationProvider>
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/components/workspaces/workspace-recency-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import { createContext, useContext } from 'react'

const EMPTY_RECENT_WORKSPACE_IDS: readonly string[] = []

const WorkspaceRecencyContext = createContext<readonly string[]>(EMPTY_RECENT_WORKSPACE_IDS)

interface WorkspaceRecencyProviderProps {
/** Most-recent-first workspace ids read from the recency cookie by the layout. */
recentWorkspaceIds: readonly string[]
children: React.ReactNode
}

/**
* Hands the server-read visit order to the workspace lists so server render and
* hydration agree with the order the browser's visit history applies after it.
*/
export function WorkspaceRecencyProvider({
recentWorkspaceIds,
children,
}: WorkspaceRecencyProviderProps) {
return (
<WorkspaceRecencyContext.Provider value={recentWorkspaceIds}>
{children}
</WorkspaceRecencyContext.Provider>
)
}

export function useInitialRecentWorkspaceIds(): readonly string[] {
return useContext(WorkspaceRecencyContext)
}
14 changes: 11 additions & 3 deletions apps/sim/hooks/use-workspace-order.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
'use client'

import { useMemo, useSyncExternalStore } from 'react'
import { useInitialRecentWorkspaceIds } from '@/components/workspaces/workspace-recency-provider'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { sortByRecentIds } from '@/lib/workspaces/recency-cookie'
import type { Workspace } from '@/hooks/queries/workspace'

const serverSnapshot = () => null

/** Layers the viewer's pins and visit history over the server's newest-first list. */
/**
* Layers the viewer's pins and visit history over the server's newest-first list.
* Server render and hydration order by the recency cookie, which mirrors the head
* of the localStorage history, so switching to that history after hydration does
* not reshuffle the rows.
*/
export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySet<string>) {
const initialRecentIds = useInitialRecentWorkspaceIds()
const recencySnapshot = useSyncExternalStore(
WorkspaceRecencyStorage.subscribe,
WorkspaceRecencyStorage.getSnapshot,
Expand All @@ -16,7 +24,7 @@ export function useWorkspaceOrder(workspaces: Workspace[], pinnedIds: ReadonlySe
return useMemo(() => {
const byRecency = recencySnapshot
? WorkspaceRecencyStorage.sortByRecency(workspaces)
: workspaces
: sortByRecentIds(workspaces, initialRecentIds)
return [...byRecency].sort((a, b) => Number(pinnedIds.has(b.id)) - Number(pinnedIds.has(a.id)))
}, [workspaces, pinnedIds, recencySnapshot])
}, [workspaces, pinnedIds, recencySnapshot, initialRecentIds])
}
6 changes: 6 additions & 0 deletions apps/sim/lib/core/utils/browser-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import {
workspaceSearchFiltersSchema,
} from '@/lib/api/contracts/knowledge/search'
import { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
import {
serializeWorkspaceRecency,
WORKSPACE_RECENCY_COOKIE,
} from '@/lib/workspaces/recency-cookie'
/**
* Safe localStorage utilities with SSR support
* Provides clean error handling and type safety for browser storage operations
Expand Down Expand Up @@ -141,8 +145,10 @@ export class WorkspaceRecencyStorage {
}
}

/** Also mirrors the head of the history into a cookie so server renders use the same order. */
private static save(map: Record<string, number>): void {
if (BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)) {
document.cookie = `${WORKSPACE_RECENCY_COOKIE}=${serializeWorkspaceRecency(map)}; path=/; max-age=31536000; samesite=lax`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Visit order survives sign-out Sign-out clears the localStorage visit history, but not this year-long cookie. If another account uses the same browser and can access some of the same workspaces, its sidebar can display those workspaces in the previous account’s visit order. Clear or scope the cookie at the identity boundary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Recency cookie survives sign-out Sign-out and identity-switch cleanup remove the localStorage visit history, but not this year-long cookie. Subsequent requests still carry the previous account’s workspace IDs, and signing back into that account can restore its old sidebar order without a new visit.

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Clear or scope workspace_recency at sign-out so another account cannot inherit this account’s workspace visit order.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/core/utils/browser-storage.ts, line 151:

<comment>Clear or scope `workspace_recency` at sign-out so another account cannot inherit this account’s workspace visit order.</comment>

<file context>
@@ -141,8 +145,10 @@ export class WorkspaceRecencyStorage {
+  /** Also mirrors the head of the history into a cookie so server renders use the same order. */
   private static save(map: Record<string, number>): void {
     if (BrowserStorage.setItem(WorkspaceRecencyStorage.KEY, map)) {
+      document.cookie = `${WORKSPACE_RECENCY_COOKIE}=${serializeWorkspaceRecency(map)}; path=/; max-age=31536000; samesite=lax`
       window.dispatchEvent(new Event(WorkspaceRecencyStorage.CHANGE_EVENT))
     }
</file context>
Fix with cubic

window.dispatchEvent(new Event(WorkspaceRecencyStorage.CHANGE_EVENT))
}
}
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/lib/workspaces/recency-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
parseWorkspaceRecency,
serializeWorkspaceRecency,
sortByRecentIds,
} from '@/lib/workspaces/recency-cookie'

describe('workspace recency cookie', () => {
it('serializes visits most recent first and round-trips through parse', () => {
const value = serializeWorkspaceRecency({ a: 1, b: 3, c: 2 })
expect(value).toBe('b.c.a')
expect(parseWorkspaceRecency(value)).toEqual(['b', 'c', 'a'])
})

it('caps the cookie to the most recent twenty workspaces', () => {
const visits = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`ws-${i}`, i]))
const ids = parseWorkspaceRecency(serializeWorkspaceRecency(visits))
expect(ids).toHaveLength(20)
expect(ids[0]).toBe('ws-29')
})

it('drops malformed ids from a tampered cookie', () => {
expect(parseWorkspaceRecency('ok-1.<script>..ok_2')).toEqual(['ok-1', 'ok_2'])
expect(parseWorkspaceRecency(undefined)).toEqual([])
})

it('orders tracked items first and keeps untracked items in incoming order', () => {
const items = [{ id: 'n1' }, { id: 't2' }, { id: 'n2' }, { id: 't1' }]
expect(sortByRecentIds(items, ['t1', 't2']).map(({ id }) => id)).toEqual([
't1',
't2',
'n1',
'n2',
])
expect(sortByRecentIds(items, [])).toBe(items)
})
})
45 changes: 45 additions & 0 deletions apps/sim/lib/workspaces/recency-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Mirrors the viewer's most recent workspace visits into a cookie so the server
* can render the workspace list in the order the browser's visit history will
* produce. Visit history itself stays in localStorage; this is only the head of
* it, capped because the cookie rides along on every request.
*/
export const WORKSPACE_RECENCY_COOKIE = 'workspace_recency'

const MAX_RECENT_WORKSPACES = 20
const SEPARATOR = '.'
const WORKSPACE_ID_PATTERN = /^[\w-]{1,64}$/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Valid IDs lose recency If an accessible legacy or imported workspace has an ID outside this 64-character, word-or-hyphen pattern, the cookie omits it even though the workspace ID contract accepts strings up to 128 characters. The server then cannot render its visit position, so its row can move after hydration. Filtering only after the 20-item cap can also leave usable cookie slots empty.

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Preserve valid workspace IDs up to the 128-character contract limit when encoding the cookie; this pattern drops them from the server order and lets hydration reshuffle those rows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/workspaces/recency-cookie.ts, line 11:

<comment>Preserve valid workspace IDs up to the 128-character contract limit when encoding the cookie; this pattern drops them from the server order and lets hydration reshuffle those rows.</comment>

<file context>
@@ -0,0 +1,45 @@
+
+const MAX_RECENT_WORKSPACES = 20
+const SEPARATOR = '.'
+const WORKSPACE_ID_PATTERN = /^[\w-]{1,64}$/
+
+/** Most-recent-first workspace ids from a visit-time map, ready for `document.cookie`. */
</file context>
Fix with cubic


/** Most-recent-first workspace ids from a visit-time map, ready for `document.cookie`. */
export function serializeWorkspaceRecency(visits: Record<string, number>): string {
return Object.entries(visits)
.sort((a, b) => b[1] - a[1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Tied visits can swap If two visits receive the same Date.now() timestamp, cookie serialization keeps their order from the visit map, while the client keeps their order from the workspace list. When those orders differ, the rows swap after hydration, leaving a small version of the refresh reshuffle.

.slice(0, MAX_RECENT_WORKSPACES)

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Filter invalid workspace IDs before applying the 20-item cap. Otherwise malformed client-side entries can displace valid recent workspaces from the cookie and reintroduce the refresh reshuffle this helper is intended to prevent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/workspaces/recency-cookie.ts, line 17:

<comment>Filter invalid workspace IDs before applying the 20-item cap. Otherwise malformed client-side entries can displace valid recent workspaces from the cookie and reintroduce the refresh reshuffle this helper is intended to prevent.</comment>

<file context>
@@ -0,0 +1,45 @@
+export function serializeWorkspaceRecency(visits: Record<string, number>): string {
+  return Object.entries(visits)
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, MAX_RECENT_WORKSPACES)
+    .map(([id]) => id)
+    .filter((id) => WORKSPACE_ID_PATTERN.test(id))
</file context>
Fix with cubic

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Older visits still reshuffle When a viewer has visited more than 20 accessible workspaces, the cookie records only the newest 20. The server leaves the remaining workspaces in creation order, but hydration switches to localStorage, which ranks every visit. Those older rows can move on refresh—the reshuffle this change is meant to prevent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Older visits still reshuffle When someone has visited more than 20 workspaces, the cookie omits older visits but localStorage keeps them. The organization sidebar can therefore render those workspaces in creation order on refresh, then move them into visit order after hydration—the reshuffle this PR aims to prevent.

.map(([id]) => id)
.filter((id) => WORKSPACE_ID_PATTERN.test(id))
.join(SEPARATOR)
}

/** Parses the cookie defensively: it is client-written, so malformed ids are dropped. */
export function parseWorkspaceRecency(value: string | undefined): string[] {
if (!value) return []
return value
.split(SEPARATOR)
.filter((id) => WORKSPACE_ID_PATTERN.test(id))
.slice(0, MAX_RECENT_WORKSPACES)
}

/**
* Orders items by their position in `recentIds`, leaving untracked items after
* them in their incoming order — the same result `sortByRecency` gives for
* the ids the cookie holds.
*/
export function sortByRecentIds<T extends { id: string }>(
items: T[],
recentIds: readonly string[]
): T[] {
if (recentIds.length === 0) return items
const rank = new Map(recentIds.map((id, index) => [id, index]))
const untracked = recentIds.length
return [...items].sort((a, b) => (rank.get(a.id) ?? untracked) - (rank.get(b.id) ?? untracked))
}
Loading