diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx
index f55676e9f04..ddaa31bf583 100644
--- a/apps/sim/app/o/[organizationId]/layout.tsx
+++ b/apps/sim/app/o/[organizationId]/layout.tsx
@@ -14,12 +14,14 @@ import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/pa
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { canBrowsePeople } from '@/lib/people/feature-flag'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
+import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
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'
import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar'
+import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch'
import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
@@ -86,6 +88,7 @@ export default async function OrganizationLayout({
projectsEnabled,
simLinksEnabled,
peopleEnabled,
+ simLinkOrganizationId,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
@@ -105,6 +108,11 @@ export default async function OrganizationLayout({
isProjectsEnabled(),
simLinksEnabledLookup,
peopleEnabledLookup,
+ resolveSimLinkOrganization({
+ kind: 'session',
+ userId: session.user.id,
+ sessionId: session.session.id,
+ }),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
@@ -123,26 +131,28 @@ export default async function OrganizationLayout({
}}
>
-
-
-
-
-
-
- }
- initialSidebarCollapsed={initialSidebarCollapsed}
- >
- {children}
-
-
-
-
+
+
+
+
+
+
+
+ }
+ initialSidebarCollapsed={initialSidebarCollapsed}
+ >
+ {children}
+
+
+
+
+
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
index 5647e2f0b6c..cc42e6536f2 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
@@ -38,12 +38,14 @@ interface SimLinkModalProps {
link?: SimLink
/** Prefills the name of a new link, such as one opened from an unknown sim/ link. */
initialName?: string
+ /** Prefills the destination of a new link, such as a search result's address. */
+ initialUrl?: string
}
-function draftOf(link: SimLink | undefined, initialName = ''): SimLinkBody {
+function draftOf(link: SimLink | undefined, initialName = '', initialUrl = ''): SimLinkBody {
return {
name: link?.name ?? initialName,
- url: link?.url ?? '',
+ url: link?.url ?? initialUrl,
defaultUrl: link?.defaultUrl ?? null,
description: link?.description ?? '',
unlisted: link?.unlisted ?? false,
@@ -52,12 +54,18 @@ function draftOf(link: SimLink | undefined, initialName = ''): SimLinkBody {
}
/** Mount per link (keyed) so each open starts from that link's saved fields. */
-export function SimLinkModal({ organizationId, onClose, link, initialName }: SimLinkModalProps) {
+export function SimLinkModal({
+ organizationId,
+ onClose,
+ link,
+ initialName,
+ initialUrl,
+}: SimLinkModalProps) {
const create = useCreateSimLink(organizationId)
const update = useUpdateSimLink(organizationId)
const mutation = link ? update : create
const nameTaken = isApiClientError(mutation.error) && mutation.error.status === 409
- const [draft, setDraft] = useState(() => draftOf(link, initialName))
+ const [draft, setDraft] = useState(() => draftOf(link, initialName, initialUrl))
const [errors, setErrors] = useState>>({})
const variable = isVariableSimLinkUrl(draft.url)
const canManage = link?.canManage ?? true
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx
new file mode 100644
index 00000000000..9f9b63c9abc
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx
@@ -0,0 +1,28 @@
+'use client'
+
+import { createContext, type ReactNode, useContext } from 'react'
+
+const SimLinkOrganizationContext = createContext(null)
+
+interface SimLinkOrganizationProviderProps {
+ /** Where `sim/` resolves for this session, or null when it resolves nowhere. */
+ organizationId: string | null
+ children: ReactNode
+}
+
+/** Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer. */
+export function SimLinkOrganizationProvider({
+ organizationId,
+ children,
+}: SimLinkOrganizationProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */
+export function useSimLinkOrganizationId(): string | null {
+ return useContext(SimLinkOrganizationContext)
+}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
new file mode 100644
index 00000000000..6fa73aa0dda
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
@@ -0,0 +1,123 @@
+'use client'
+
+import { type ReactNode, useState } from 'react'
+import { Chip, DropdownMenuItem, toast, useCopyToClipboard } from '@sim/emcn'
+import { Check, Link, Plus } from '@sim/emcn/icons'
+import { simLinkUrl } from '@/lib/navigation/paths'
+import { suggestSimLinkName } from '@/lib/sim-links/validation'
+import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
+import { useSimLinkOrganizationId } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
+import { useSimLinksForUrl } from '@/hooks/queries/sim-links'
+
+interface SimLinkCreator {
+ /** Null where Sim links are off; render no shortcut then. */
+ organizationId: string | null
+ openCreate: () => void
+ /** The new-link form while it is open; render it outside any menu, which unmounts on close. */
+ modal: ReactNode
+}
+
+/** Opens the new-link form for `url`, prefilled with a name suggested from `title`. */
+export function useSimLinkCreator(url: string, title: string): SimLinkCreator {
+ const organizationId = useSimLinkOrganizationId()
+ const [open, setOpen] = useState(false)
+ return {
+ organizationId,
+ openCreate: () => setOpen(true),
+ modal:
+ open && organizationId ? (
+ setOpen(false)}
+ />
+ ) : null,
+ }
+}
+
+/** The first link that already opens `url`, and a copy of its address. */
+function useExistingSimLink(organizationId: string, url: string) {
+ const { data, isPending, isError } = useSimLinksForUrl(organizationId, url)
+ const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
+ const existing = data?.at(0)
+ const copyExisting = async () => {
+ if (!existing) return false
+ const done = await copy(simLinkUrl(window.location.origin, existing.name))
+ if (!done) toast.error('Unable to copy Sim link')
+ return done
+ }
+ return { existing, isPending, isError, copied, copyExisting }
+}
+
+interface SimLinkShortcutProps {
+ organizationId: string
+ url: string
+ onCreate: () => void
+}
+
+/**
+ * The Sim link entry of an actions menu: copies the link that already opens this address, so a
+ * menu never offers a duplicate, or offers to create one. It looks up when the menu opens, and
+ * offers nothing when the lookup fails rather than risk a duplicate.
+ */
+export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortcutProps) {
+ const { existing, isPending, isError, copyExisting } = useExistingSimLink(organizationId, url)
+ if (isError) return null
+ if (existing) {
+ return (
+ {
+ if (await copyExisting()) toast.success(`Copied sim/${existing.name}`)
+ }}
+ >
+
+ Copy sim/{existing.name}
+
+ )
+ }
+ return (
+
+
+ Create Sim link
+
+ )
+}
+
+function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) {
+ const { existing, isPending, isError, copied, copyExisting } = useExistingSimLink(
+ organizationId,
+ url
+ )
+ if (isError) return null
+ if (existing) {
+ return (
+ void copyExisting()}>
+ {copied ? 'Copied!' : `Copy sim/${existing.name}`}
+
+ )
+ }
+ return (
+
+ Create Sim link
+
+ )
+}
+
+interface SimLinkChipProps {
+ url: string
+ /** The title a new link's name is suggested from. */
+ title: string
+}
+
+/** The same choice as a footer chip, for a dialog that already shows the address it shortens. */
+export function SimLinkChip({ url, title }: SimLinkChipProps) {
+ const { organizationId, openCreate, modal } = useSimLinkCreator(url, title)
+ if (!organizationId) return null
+ return (
+ <>
+
+ {modal}
+ >
+ )
+}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx
index 7f00eae3bff..35734bf2a14 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx
@@ -6,7 +6,7 @@ import { Link } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useQueryStates } from 'nuqs'
import type { SimLink } from '@/lib/api/contracts/sim-links'
-import { simLinkPath } from '@/lib/navigation/paths'
+import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths'
import { toSimLinkName } from '@/lib/sim-links/validation'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import {
@@ -62,7 +62,7 @@ function openLink(name: string) {
async function copyLink(name: string) {
try {
- await writeTextToClipboard(new URL(simLinkPath(name), window.location.origin).href)
+ await writeTextToClipboard(simLinkUrl(window.location.origin, name))
} catch (error) {
logger.error('Failed to copy Sim link', { error })
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
index 5d354b61e49..2fab30124ab 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx
@@ -20,6 +20,7 @@ import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
+import { SimLinkChip } from '@/app/o/[organizationId]/links/sim-link-shortcut'
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -264,6 +265,11 @@ export function ShareModal({
),
},
+ {
+ custom: (
+
+ ),
+ },
]
: undefined
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
index a9c43d7af35..dc78f8b2abc 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx
@@ -20,6 +20,10 @@ import { Check, Link as LinkIcon, MoreHorizontal, Sparkles } from '@sim/emcn/ico
import { formatDate } from '@sim/utils/formatting'
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
import { inter } from '@/app/_styles/fonts/inter/inter'
+import {
+ SimLinkMenuItem,
+ useSimLinkCreator,
+} from '@/app/o/[organizationId]/links/sim-link-shortcut'
import {
SourceIcon,
sourceLabel,
@@ -72,31 +76,42 @@ interface SourceActionsProps {
function SourceActions({ source, onSummarize }: SourceActionsProps) {
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
+ const simLink = useSimLinkCreator(source.url, sourceLabel(source))
return (
-
-
-
-
-
- {onSummarize && (
- onSummarize(source)}>
-
- Summarize
+ <>
+
+
+
+
+
+ {onSummarize && (
+ onSummarize(source)}>
+
+ Summarize
+
+ )}
+ {
+ if (!(await copy(source.url))) toast.error('Unable to copy link')
+ }}
+ >
+
+ Copy link
- )}
- {
- if (!(await copy(source.url))) toast.error('Unable to copy link')
- }}
- >
-
- Copy link
-
-
-
+ {simLink.organizationId && (
+
+ )}
+
+
+ {simLink.modal}
+ >
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index b43f3647e73..fb9ac90703f 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -16,9 +16,11 @@ import {
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
+import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { ApplicationLoading } from '@/app/_shell/application-loading'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
+import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
@@ -134,6 +136,7 @@ async function WorkspaceContent({
desktopExecutorRegistered,
computerUseEnabled,
projectsEnabled,
+ simLinkOrganizationId,
] = await Promise.all([
cookies(),
orgSettings,
@@ -153,6 +156,7 @@ async function WorkspaceContent({
hasDesktopBackgroundExecutor(session.user.id),
isComputerUseAvailable(hostContext.hostOrganizationId),
isProjectsEnabled(),
+ resolveSimLinkOrganization(principal),
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
])
@@ -172,43 +176,45 @@ async function WorkspaceContent({
}}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- initialSidebarCollapsed={initialSidebarCollapsed}
- >
- {children}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ initialSidebarCollapsed={initialSidebarCollapsed}
+ >
+ {children}
+
+
+
+
+
+
+
diff --git a/apps/sim/hooks/queries/sim-links.ts b/apps/sim/hooks/queries/sim-links.ts
index 5501bcc8d5a..425f6e3157a 100644
--- a/apps/sim/hooks/queries/sim-links.ts
+++ b/apps/sim/hooks/queries/sim-links.ts
@@ -2,6 +2,7 @@ import {
keepPreviousData,
useInfiniteQuery,
useMutation,
+ useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
@@ -22,6 +23,8 @@ const simLinkKeys = {
lists: (organizationId: string) => [...simLinkKeys.all, 'list', organizationId] as const,
list: (organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection) =>
[...simLinkKeys.lists(organizationId), search, sort, dir] as const,
+ forUrl: (organizationId: string, url: string) =>
+ [...simLinkKeys.lists(organizationId), 'url', url] as const,
}
export function useSimLinks(
@@ -45,6 +48,24 @@ export function useSimLinks(
})
}
+/**
+ * Links whose destination is exactly `url`. Shares the list key prefix, so creating a link
+ * refreshes it.
+ */
+export function useSimLinksForUrl(organizationId: string, url: string) {
+ return useQuery({
+ queryKey: simLinkKeys.forUrl(organizationId, url),
+ queryFn: ({ signal }) =>
+ requestJson(listSimLinksContract, {
+ params: { id: organizationId },
+ query: { url },
+ signal,
+ }),
+ select: (page) => page.links,
+ staleTime: SIM_LINKS_STALE_TIME,
+ })
+}
+
function useInvalidateSimLinks(organizationId: string) {
const queryClient = useQueryClient()
return () => queryClient.invalidateQueries({ queryKey: simLinkKeys.lists(organizationId) })
diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts
index dff8d0983f3..12b5ee677b4 100644
--- a/apps/sim/lib/api/contracts/sim-links.ts
+++ b/apps/sim/lib/api/contracts/sim-links.ts
@@ -38,6 +38,8 @@ const linkResponse = z.object({ link: simLinkSchema })
export const listSimLinksQuerySchema = z
.object({
search: z.string().max(200).optional(),
+ /** Only links whose destination is exactly this URL, such as one shown in a search result. */
+ url: z.string().min(1).max(2048).optional(),
cursor: z.string().min(1).max(128).optional(),
sort: z.enum(SIM_LINK_SORTS).optional(),
dir: z.enum(SORT_DIRECTIONS).optional(),
diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts
index b85b15397ab..18f18ae0afd 100644
--- a/apps/sim/lib/navigation/paths.ts
+++ b/apps/sim/lib/navigation/paths.ts
@@ -64,6 +64,11 @@ export function simLinkPath(...segments: string[]): string {
return ['/sim', ...segments.map(encodeURIComponent)].join('/')
}
+/** The full address of `sim/` on `origin`, which opens it with or without the extension. */
+export function simLinkUrl(origin: string, name: string): string {
+ return new URL(simLinkPath(name), origin).href
+}
+
/** Whether `pathname` is `root` itself or a route beneath it. */
export function isPathOrDescendant(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`)
diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts
index 93d2c435327..5711c4b2f3a 100644
--- a/apps/sim/lib/sim-links/application/use-cases.ts
+++ b/apps/sim/lib/sim-links/application/use-cases.ts
@@ -152,6 +152,7 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({
}: OrganizationUseCaseContext<
OrganizationInput & {
search?: string
+ url?: string
cursor?: string
sort?: SimLinkSort
dir?: SortDirection
diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts
index db8f945ed21..a8734ff9fb2 100644
--- a/apps/sim/lib/sim-links/repository.ts
+++ b/apps/sim/lib/sim-links/repository.ts
@@ -82,16 +82,18 @@ export async function listSimLinks(
viewer: SimLinkViewer,
{
search,
+ url,
cursor,
sort = 'name',
dir = sort === 'popular' ? 'desc' : 'asc',
- }: { search?: string; cursor?: string; sort?: SimLinkSort; dir?: SortDirection }
+ }: { search?: string; url?: string; cursor?: string; sort?: SimLinkSort; dir?: SortDirection }
): Promise<{ links: SimLinkRecord[]; nextCursor: string | null }> {
const [order, after] = dir === 'asc' ? ([asc, gt] as const) : ([desc, lt] as const)
const conditions: (SQL | undefined)[] = [eq(simLink.organizationId, organizationId)]
if (!viewer.isAdmin) {
conditions.push(or(eq(simLink.unlisted, false), eq(simLink.createdBy, viewer.userId)))
}
+ if (url) conditions.push(eq(simLink.url, url))
const query = search?.trim().replace(/^sim\//i, '')
if (query) {
const pattern = `%${escapeLikePattern(query)}%`
diff --git a/apps/sim/lib/sim-links/validation.test.ts b/apps/sim/lib/sim-links/validation.test.ts
new file mode 100644
index 00000000000..c2b9597837e
--- /dev/null
+++ b/apps/sim/lib/sim-links/validation.test.ts
@@ -0,0 +1,23 @@
+import { SIM_LINK_NAME_MAX_LENGTH } from '@sim/browser-extension-protocol'
+import { describe, expect, it } from 'vitest'
+import { simLinkNameSchema, suggestSimLinkName } from '@/lib/sim-links/validation'
+
+describe('suggestSimLinkName', () => {
+ it('turns a document title into a valid lower-case name', () => {
+ const name = suggestSimLinkName(' Q3 Board Deck — Final (v2)! ')
+ expect(name).toBe('q3-board-deck-final-v2')
+ expect(simLinkNameSchema.safeParse(name).success).toBe(true)
+ })
+
+ it('cuts a long title to the name limit without a trailing hyphen', () => {
+ const word = 'a'.repeat(SIM_LINK_NAME_MAX_LENGTH - 1)
+ const name = suggestSimLinkName(`${word} appendix`)
+ expect(name).toBe(word)
+ expect(simLinkNameSchema.safeParse(name).success).toBe(true)
+ })
+
+ it('suggests nothing when no letter or digit survives', () => {
+ expect(suggestSimLinkName('「年度計画」')).toBe('')
+ expect(suggestSimLinkName('... _-_ ...')).toBe('')
+ })
+})
diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts
index a7d4011b11c..c49a143f32a 100644
--- a/apps/sim/lib/sim-links/validation.ts
+++ b/apps/sim/lib/sim-links/validation.ts
@@ -34,6 +34,19 @@ export function toSimLinkName(input: string): string {
.replace(NAME_DISALLOWED, '')
}
+/**
+ * A starting name for a link made from a page's title: lower case, words joined by single hyphens,
+ * cut to the name length limit. Empty when no letter or digit survives, so the person types one
+ * instead of being handed a name that is only punctuation.
+ */
+export function suggestSimLinkName(title: string): string {
+ const name = toSimLinkName(title.trim().toLowerCase())
+ .replace(/-{2,}/g, '-')
+ .slice(0, SIM_LINK_NAME_MAX_LENGTH)
+ .replace(/^-+|-+$/g, '')
+ return /[a-z0-9]/.test(name) ? name : ''
+}
+
/** Sentence punctuation and closing quotes or brackets that follow a link pasted from prose. */
const PASTED_TRAILING_PUNCTUATION = /[.,;:!?'"’”)\]}>]+$/
diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts
index 683383c1ccd..b1ca6b15841 100644
--- a/apps/sim/scripts/test-sim-links-e2e.ts
+++ b/apps/sim/scripts/test-sim-links-e2e.ts
@@ -288,6 +288,17 @@ async function run() {
assert.deepEqual(await listedNames(member, { search: 'everything lives' }), ['Team-Handbook'])
})
+ await check('a destination lookup finds only links that open exactly that address', async () => {
+ await create(member, { name: 'plan', url: 'https://plan.example.com' })
+ await create(member, { name: 'plan-faq', url: 'https://plan.example.com/faq' })
+ assert.deepEqual(await listedNames(member, { url: 'https://plan.example.com' }), ['plan'])
+ assert.deepEqual(await listedNames(owner, { url: 'https://plan.example.com' }), [
+ 'plan',
+ 'secret-plan',
+ ])
+ assert.deepEqual(await listedNames(member, { url: 'https://plan.example.com/missing' }), [])
+ })
+
await check('restricted links refuse edits from other members', async () => {
const restricted = await create(owner, {
name: 'payroll',