Skip to content

Commit 8af0197

Browse files
committed
fix(sim-links): scope result shortcuts to the organization sim/ resolves, hide on failed lookups
1 parent 0ddd613 commit 8af0197

8 files changed

Lines changed: 120 additions & 95 deletions

File tree

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/pa
1414
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
1515
import { canBrowsePeople } from '@/lib/people/feature-flag'
1616
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
17+
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
1718
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
1819
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1920
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
2021
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2122
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
2223
import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
24+
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
2325
import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch'
2426
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
2527
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
@@ -86,6 +88,7 @@ export default async function OrganizationLayout({
8688
projectsEnabled,
8789
simLinksEnabled,
8890
peopleEnabled,
91+
simLinkOrganizationId,
8992
] = await Promise.all([
9093
prefetchOrganizationSidebar(
9194
queryClient,
@@ -105,6 +108,11 @@ export default async function OrganizationLayout({
105108
isProjectsEnabled(),
106109
simLinksEnabledLookup,
107110
peopleEnabledLookup,
111+
resolveSimLinkOrganization({
112+
kind: 'session',
113+
userId: session.user.id,
114+
sessionId: session.session.id,
115+
}),
108116
])
109117
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
110118

@@ -120,30 +128,31 @@ export default async function OrganizationLayout({
120128
'mothership-plan-mode': benchmarkEnabled,
121129
'mothership-memory-spaces': benchmarkEnabled,
122130
projects: projectsEnabled,
123-
'sim-links': simLinksEnabled,
124131
}}
125132
>
126133
<OrganizationProvider context={context}>
127-
<GlobalCommandsProvider>
128-
<WorkspaceViewport className='workspace-root'>
129-
<ImpersonationBanner />
130-
<SessionExpired />
131-
<SettingsNavigationProvider>
132-
<WorkspaceChrome
133-
sidebar={
134-
<OrganizationSidebar
135-
benchmarkEnabled={benchmarkEnabled}
136-
simLinksEnabled={simLinksEnabled}
137-
peopleEnabled={peopleEnabled}
138-
/>
139-
}
140-
initialSidebarCollapsed={initialSidebarCollapsed}
141-
>
142-
{children}
143-
</WorkspaceChrome>
144-
</SettingsNavigationProvider>
145-
</WorkspaceViewport>
146-
</GlobalCommandsProvider>
134+
<SimLinkOrganizationProvider organizationId={simLinkOrganizationId}>
135+
<GlobalCommandsProvider>
136+
<WorkspaceViewport className='workspace-root'>
137+
<ImpersonationBanner />
138+
<SessionExpired />
139+
<SettingsNavigationProvider>
140+
<WorkspaceChrome
141+
sidebar={
142+
<OrganizationSidebar
143+
benchmarkEnabled={benchmarkEnabled}
144+
simLinksEnabled={simLinksEnabled}
145+
peopleEnabled={peopleEnabled}
146+
/>
147+
}
148+
initialSidebarCollapsed={initialSidebarCollapsed}
149+
>
150+
{children}
151+
</WorkspaceChrome>
152+
</SettingsNavigationProvider>
153+
</WorkspaceViewport>
154+
</GlobalCommandsProvider>
155+
</SimLinkOrganizationProvider>
147156
</OrganizationProvider>
148157
</FeatureFlagsProvider>
149158
</HydrationBoundary>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client'
2+
3+
import { createContext, type ReactNode, useContext } from 'react'
4+
5+
const SimLinkOrganizationContext = createContext<string | null>(null)
6+
7+
interface SimLinkOrganizationProviderProps {
8+
/** Where `sim/<name>` resolves for this session, or null when it resolves nowhere. */
9+
organizationId: string | null
10+
children: ReactNode
11+
}
12+
13+
/** Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer. */
14+
export function SimLinkOrganizationProvider({
15+
organizationId,
16+
children,
17+
}: SimLinkOrganizationProviderProps) {
18+
return (
19+
<SimLinkOrganizationContext.Provider value={organizationId}>
20+
{children}
21+
</SimLinkOrganizationContext.Provider>
22+
)
23+
}
24+
25+
/** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */
26+
export function useSimLinkOrganizationId(): string | null {
27+
return useContext(SimLinkOrganizationContext)
28+
}

‎apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx‎

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,9 @@ import { Check, Link, Plus } from '@sim/emcn/icons'
66
import { simLinkUrl } from '@/lib/navigation/paths'
77
import { suggestSimLinkName } from '@/lib/sim-links/validation'
88
import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
9-
import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
10-
import { useOptionalFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
11-
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
9+
import { useSimLinkOrganizationId } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
1210
import { useSimLinksForUrl } from '@/hooks/queries/sim-links'
1311

14-
/**
15-
* The organization a Sim link made on this surface belongs to: the organization being viewed, or
16-
* the one that owns the current workspace. Null where Sim links are off, so the action is hidden.
17-
*/
18-
function useSimLinkOrganizationId(): string | null {
19-
const enabled = useOptionalFeatureFlag('sim-links')
20-
const organization = useOptionalOrganizationContext()
21-
const host = useOptionalWorkspaceHostContext()
22-
if (!enabled) return null
23-
return organization?.organization.id ?? host?.hostOrganizationId ?? null
24-
}
25-
2612
interface SimLinkCreator {
2713
/** Null where Sim links are off; render no shortcut then. */
2814
organizationId: string | null
@@ -52,7 +38,7 @@ export function useSimLinkCreator(url: string, title: string): SimLinkCreator {
5238

5339
/** The first link that already opens `url`, and a copy of its address. */
5440
function useExistingSimLink(organizationId: string, url: string) {
55-
const { data, isPending } = useSimLinksForUrl(organizationId, url)
41+
const { data, isPending, isError } = useSimLinksForUrl(organizationId, url)
5642
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
5743
const existing = data?.at(0)
5844
const copyExisting = async () => {
@@ -61,7 +47,7 @@ function useExistingSimLink(organizationId: string, url: string) {
6147
if (!done) toast.error('Unable to copy Sim link')
6248
return done
6349
}
64-
return { existing, isPending, copied, copyExisting }
50+
return { existing, isPending, isError, copied, copyExisting }
6551
}
6652

6753
interface SimLinkShortcutProps {
@@ -72,10 +58,12 @@ interface SimLinkShortcutProps {
7258

7359
/**
7460
* The Sim link entry of an actions menu: copies the link that already opens this address, so a
75-
* menu never offers a duplicate, or offers to create one. It looks up when the menu opens.
61+
* menu never offers a duplicate, or offers to create one. It looks up when the menu opens, and
62+
* offers nothing when the lookup fails rather than risk a duplicate.
7663
*/
7764
export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortcutProps) {
78-
const { existing, isPending, copyExisting } = useExistingSimLink(organizationId, url)
65+
const { existing, isPending, isError, copyExisting } = useExistingSimLink(organizationId, url)
66+
if (isError) return null
7967
if (existing) {
8068
return (
8169
<DropdownMenuItem
@@ -97,7 +85,11 @@ export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortc
9785
}
9886

9987
function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) {
100-
const { existing, isPending, copied, copyExisting } = useExistingSimLink(organizationId, url)
88+
const { existing, isPending, isError, copied, copyExisting } = useExistingSimLink(
89+
organizationId,
90+
url
91+
)
92+
if (isError) return null
10193
if (existing) {
10294
return (
10395
<Chip leftIcon={copied ? Check : Link} onClick={() => void copyExisting()}>

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import {
1616
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
1717
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
1818
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
19-
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
19+
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
2020
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
2121
import { ApplicationLoading } from '@/app/_shell/application-loading'
2222
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
23+
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
2324
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
2425
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
2526
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
@@ -135,7 +136,7 @@ async function WorkspaceContent({
135136
desktopExecutorRegistered,
136137
computerUseEnabled,
137138
projectsEnabled,
138-
simLinksEnabled,
139+
simLinkOrganizationId,
139140
] = await Promise.all([
140141
cookies(),
141142
orgSettings,
@@ -155,9 +156,7 @@ async function WorkspaceContent({
155156
hasDesktopBackgroundExecutor(session.user.id),
156157
isComputerUseAvailable(hostContext.hostOrganizationId),
157158
isProjectsEnabled(),
158-
hostContext.hostOrganizationId
159-
? isSimLinksEnabled(hostContext.hostOrganizationId)
160-
: Promise.resolve(false),
159+
resolveSimLinkOrganization(principal),
161160
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
162161
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
163162
])
@@ -174,47 +173,48 @@ async function WorkspaceContent({
174173
'mothership-computer-use': computerUseEnabled,
175174
'mothership-plan-mode': planModeEnabled,
176175
projects: projectsEnabled,
177-
'sim-links': simLinksEnabled,
178176
}}
179177
>
180178
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
181-
<BrandingProvider
182-
hostOrganizationId={hostContext.hostOrganizationId}
183-
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
184-
initialOrgSettings={initialOrgSettings}
185-
>
186-
<DesktopOAuthConnectListener />
187-
<InFlightUploadGuard />
188-
<SettingsLoader />
189-
<ProviderModelsLoader />
190-
<CustomBlocksLoader />
191-
<BlockVisibilityLoader />
192-
<GlobalCommandsProvider>
193-
<WorkspaceViewport>
194-
<ImpersonationBanner />
195-
<SessionExpired />
196-
<WorkspacePermissionsProvider>
197-
<WorkspaceScopeSync />
198-
<SettingsNavigationProvider>
199-
<WorkspaceChrome
200-
sidebar={
201-
<Sidebar
202-
organizationHref={organizationHref}
203-
desktopExecutor={{
204-
available: isDesktopBackgroundExecutorAvailable(),
205-
registered: desktopExecutorRegistered,
206-
}}
207-
/>
208-
}
209-
initialSidebarCollapsed={initialSidebarCollapsed}
210-
>
211-
{children}
212-
</WorkspaceChrome>
213-
</SettingsNavigationProvider>
214-
</WorkspacePermissionsProvider>
215-
</WorkspaceViewport>
216-
</GlobalCommandsProvider>
217-
</BrandingProvider>
179+
<SimLinkOrganizationProvider organizationId={simLinkOrganizationId}>
180+
<BrandingProvider
181+
hostOrganizationId={hostContext.hostOrganizationId}
182+
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
183+
initialOrgSettings={initialOrgSettings}
184+
>
185+
<DesktopOAuthConnectListener />
186+
<InFlightUploadGuard />
187+
<SettingsLoader />
188+
<ProviderModelsLoader />
189+
<CustomBlocksLoader />
190+
<BlockVisibilityLoader />
191+
<GlobalCommandsProvider>
192+
<WorkspaceViewport>
193+
<ImpersonationBanner />
194+
<SessionExpired />
195+
<WorkspacePermissionsProvider>
196+
<WorkspaceScopeSync />
197+
<SettingsNavigationProvider>
198+
<WorkspaceChrome
199+
sidebar={
200+
<Sidebar
201+
organizationHref={organizationHref}
202+
desktopExecutor={{
203+
available: isDesktopBackgroundExecutorAvailable(),
204+
registered: desktopExecutorRegistered,
205+
}}
206+
/>
207+
}
208+
initialSidebarCollapsed={initialSidebarCollapsed}
209+
>
210+
{children}
211+
</WorkspaceChrome>
212+
</SettingsNavigationProvider>
213+
</WorkspacePermissionsProvider>
214+
</WorkspaceViewport>
215+
</GlobalCommandsProvider>
216+
</BrandingProvider>
217+
</SimLinkOrganizationProvider>
218218
</WorkspaceHostProvider>
219219
</FeatureFlagsProvider>
220220
</HydrationBoundary>

‎apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface WorkspaceFeatureFlags {
1111
'mothership-computer-use'?: boolean
1212
'mothership-memory-spaces'?: boolean
1313
projects?: boolean
14-
'sim-links'?: boolean
1514
}
1615

1716
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
@@ -32,8 +31,3 @@ export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean {
3231
if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider')
3332
return flags[name] ?? false
3433
}
35-
36-
/** The same flag for shared components that also render outside one, where every flag is off. */
37-
export function useOptionalFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean {
38-
return useContext(FeatureFlagsContext)?.[name] ?? false
39-
}

‎apps/sim/lib/api/contracts/sim-links.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const listSimLinksQuerySchema = z
3939
.object({
4040
search: z.string().max(200).optional(),
4141
/** Only links whose destination is exactly this URL, such as one shown in a search result. */
42-
url: z.string().max(2048).optional(),
42+
url: z.string().min(1).max(2048).optional(),
4343
cursor: z.string().min(1).max(128).optional(),
4444
sort: z.enum(SIM_LINK_SORTS).optional(),
4545
dir: z.enum(SORT_DIRECTIONS).optional(),

‎apps/sim/lib/sim-links/validation.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ describe('suggestSimLinkName', () => {
1616
expect(simLinkNameSchema.safeParse(name).success).toBe(true)
1717
})
1818

19-
it('suggests nothing when the title has no character a name can hold', () => {
19+
it('suggests nothing when no letter or digit survives', () => {
2020
expect(suggestSimLinkName('「年度計画」')).toBe('')
21+
expect(suggestSimLinkName('... _-_ ...')).toBe('')
2122
})
2223
})

‎apps/sim/lib/sim-links/validation.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ export function toSimLinkName(input: string): string {
3636

3737
/**
3838
* A starting name for a link made from a page's title: lower case, words joined by single hyphens,
39-
* cut to the name length limit. Empty when the title has no character a name can hold, so the
40-
* person types one instead.
39+
* cut to the name length limit. Empty when no letter or digit survives, so the person types one
40+
* instead of being handed a name that is only punctuation.
4141
*/
4242
export function suggestSimLinkName(title: string): string {
43-
return toSimLinkName(title.trim().toLowerCase())
43+
const name = toSimLinkName(title.trim().toLowerCase())
4444
.replace(/-{2,}/g, '-')
4545
.slice(0, SIM_LINK_NAME_MAX_LENGTH)
4646
.replace(/^-+|-+$/g, '')
47+
return /[a-z0-9]/.test(name) ? name : ''
4748
}
4849

4950
/** Sentence punctuation and closing quotes or brackets that follow a link pasted from prose. */

0 commit comments

Comments
 (0)