-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(sidebar): render workspace recency order on the server to stop refresh reshuffle #8281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
|
|
@@ -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)}> | ||
|
|
@@ -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}> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| <WorkspaceChrome | ||
| sidebar={<OrganizationSidebar />} | ||
| initialSidebarCollapsed={initialSidebarCollapsed} | ||
| > | ||
| {children} | ||
| </WorkspaceChrome> | ||
| </WorkspaceRecencyProvider> | ||
| </div> | ||
| </GlobalCommandsProvider> | ||
| </OrganizationProvider> | ||
|
|
||
| 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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Clear or scope Prompt for AI agents |
||
| window.dispatchEvent(new Event(WorkspaceRecencyStorage.CHANGE_EVENT)) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| }) | ||
| }) |
| 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}$/ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| /** 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]) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| .slice(0, MAX_RECENT_WORKSPACES) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| .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)) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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_recencycookie to the inventory (it is genuinely set, withmax-age=31536000inapps/sim/lib/core/utils/browser-storage.ts, so the "1 year" retention is accurate) without bumpinglastUpdated, which still reads 'September 17, 2026'. UpdatelastUpdatedto the publication date so the policy stays consistent with its own freshness statement.Prompt for AI agents