From 0ddd613bb8eee93a1e355e50e4db6068a3d5a049 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 16:36:26 -0800 Subject: [PATCH 1/2] feat(sim-links): create or copy a Sim link from search results and file shares --- apps/sim/app/o/[organizationId]/layout.tsx | 1 + .../[organizationId]/links/sim-link-modal.tsx | 16 ++- .../links/sim-link-shortcut.tsx | 131 ++++++++++++++++++ .../o/[organizationId]/links/sim-links.tsx | 4 +- .../components/share-modal/share-modal.tsx | 6 + .../components/source-card/source-card.tsx | 61 +++++--- .../app/workspace/[workspaceId]/layout.tsx | 6 + .../providers/feature-flags-provider.tsx | 6 + apps/sim/hooks/queries/sim-links.ts | 21 +++ apps/sim/lib/api/contracts/sim-links.ts | 2 + apps/sim/lib/navigation/paths.ts | 5 + .../lib/sim-links/application/use-cases.ts | 1 + apps/sim/lib/sim-links/repository.ts | 4 +- apps/sim/lib/sim-links/validation.test.ts | 22 +++ apps/sim/lib/sim-links/validation.ts | 12 ++ apps/sim/scripts/test-sim-links-e2e.ts | 11 ++ 16 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx create mode 100644 apps/sim/lib/sim-links/validation.test.ts diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index f55676e9f04..ed6eabb5f45 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -120,6 +120,7 @@ export default async function OrganizationLayout({ 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, projects: projectsEnabled, + 'sim-links': simLinksEnabled, }} > 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-shortcut.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx new file mode 100644 index 00000000000..01ceb3e6bd0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx @@ -0,0 +1,131 @@ +'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 { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useOptionalFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useSimLinksForUrl } from '@/hooks/queries/sim-links' + +/** + * The organization a Sim link made on this surface belongs to: the organization being viewed, or + * the one that owns the current workspace. Null where Sim links are off, so the action is hidden. + */ +function useSimLinkOrganizationId(): string | null { + const enabled = useOptionalFeatureFlag('sim-links') + const organization = useOptionalOrganizationContext() + const host = useOptionalWorkspaceHostContext() + if (!enabled) return null + return organization?.organization.id ?? host?.hostOrganizationId ?? null +} + +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 } = 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, 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. + */ +export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortcutProps) { + const { existing, isPending, copyExisting } = useExistingSimLink(organizationId, url) + 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, copied, copyExisting } = useExistingSimLink(organizationId, url) + 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..ad7ec11a63a 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -16,6 +16,7 @@ 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 { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { ApplicationLoading } from '@/app/_shell/application-loading' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -134,6 +135,7 @@ async function WorkspaceContent({ desktopExecutorRegistered, computerUseEnabled, projectsEnabled, + simLinksEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -153,6 +155,9 @@ async function WorkspaceContent({ hasDesktopBackgroundExecutor(session.user.id), isComputerUseAvailable(hostContext.hostOrganizationId), isProjectsEnabled(), + hostContext.hostOrganizationId + ? isSimLinksEnabled(hostContext.hostOrganizationId) + : Promise.resolve(false), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -169,6 +174,7 @@ async function WorkspaceContent({ 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, + 'sim-links': simLinksEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index f33fd8f230f..5de69adbe69 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -11,6 +11,7 @@ export interface WorkspaceFeatureFlags { 'mothership-computer-use'?: boolean 'mothership-memory-spaces'?: boolean projects?: boolean + 'sim-links'?: boolean } const FeatureFlagsContext = createContext(null) @@ -31,3 +32,8 @@ export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider') return flags[name] ?? false } + +/** The same flag for shared components that also render outside one, where every flag is off. */ +export function useOptionalFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { + return useContext(FeatureFlagsContext)?.[name] ?? false +} 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..2ebebf42144 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().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..d410b1fd142 --- /dev/null +++ b/apps/sim/lib/sim-links/validation.test.ts @@ -0,0 +1,22 @@ +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 the title has no character a name can hold', () => { + expect(suggestSimLinkName('「年度計画」')).toBe('') + }) +}) diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index a7d4011b11c..f29e4928162 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -34,6 +34,18 @@ 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 the title has no character a name can hold, so the + * person types one instead. + */ +export function suggestSimLinkName(title: string): string { + return toSimLinkName(title.trim().toLowerCase()) + .replace(/-{2,}/g, '-') + .slice(0, SIM_LINK_NAME_MAX_LENGTH) + .replace(/^-+|-+$/g, '') +} + /** 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', From 8af019768bb03fc6245d0f8f96fa9bad2e1a9ec0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 17:06:45 -0800 Subject: [PATCH 2/2] fix(sim-links): scope result shortcuts to the organization sim/ resolves, hide on failed lookups --- apps/sim/app/o/[organizationId]/layout.tsx | 51 ++++++----- .../links/sim-link-organization-provider.tsx | 28 ++++++ .../links/sim-link-shortcut.tsx | 32 +++---- .../app/workspace/[workspaceId]/layout.tsx | 86 +++++++++---------- .../providers/feature-flags-provider.tsx | 6 -- apps/sim/lib/api/contracts/sim-links.ts | 2 +- apps/sim/lib/sim-links/validation.test.ts | 3 +- apps/sim/lib/sim-links/validation.ts | 7 +- 8 files changed, 120 insertions(+), 95 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index ed6eabb5f45..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' @@ -120,30 +128,31 @@ export default async function OrganizationLayout({ 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, projects: projectsEnabled, - 'sim-links': simLinksEnabled, }} > - - - - - - - } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - - - - + + + + + + + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + + + + + 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 index 01ceb3e6bd0..6fa73aa0dda 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx @@ -6,23 +6,9 @@ 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 { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { useOptionalFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' -import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useSimLinkOrganizationId } from '@/app/o/[organizationId]/links/sim-link-organization-provider' import { useSimLinksForUrl } from '@/hooks/queries/sim-links' -/** - * The organization a Sim link made on this surface belongs to: the organization being viewed, or - * the one that owns the current workspace. Null where Sim links are off, so the action is hidden. - */ -function useSimLinkOrganizationId(): string | null { - const enabled = useOptionalFeatureFlag('sim-links') - const organization = useOptionalOrganizationContext() - const host = useOptionalWorkspaceHostContext() - if (!enabled) return null - return organization?.organization.id ?? host?.hostOrganizationId ?? null -} - interface SimLinkCreator { /** Null where Sim links are off; render no shortcut then. */ organizationId: string | null @@ -52,7 +38,7 @@ export function useSimLinkCreator(url: string, title: string): SimLinkCreator { /** The first link that already opens `url`, and a copy of its address. */ function useExistingSimLink(organizationId: string, url: string) { - const { data, isPending } = useSimLinksForUrl(organizationId, url) + const { data, isPending, isError } = useSimLinksForUrl(organizationId, url) const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) const existing = data?.at(0) const copyExisting = async () => { @@ -61,7 +47,7 @@ function useExistingSimLink(organizationId: string, url: string) { if (!done) toast.error('Unable to copy Sim link') return done } - return { existing, isPending, copied, copyExisting } + return { existing, isPending, isError, copied, copyExisting } } interface SimLinkShortcutProps { @@ -72,10 +58,12 @@ interface SimLinkShortcutProps { /** * 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. + * 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, copyExisting } = useExistingSimLink(organizationId, url) + const { existing, isPending, isError, copyExisting } = useExistingSimLink(organizationId, url) + if (isError) return null if (existing) { return ( void copyExisting()}> diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index ad7ec11a63a..fb9ac90703f 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -16,10 +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 { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' +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' @@ -135,7 +136,7 @@ async function WorkspaceContent({ desktopExecutorRegistered, computerUseEnabled, projectsEnabled, - simLinksEnabled, + simLinkOrganizationId, ] = await Promise.all([ cookies(), orgSettings, @@ -155,9 +156,7 @@ async function WorkspaceContent({ hasDesktopBackgroundExecutor(session.user.id), isComputerUseAvailable(hostContext.hostOrganizationId), isProjectsEnabled(), - hostContext.hostOrganizationId - ? isSimLinksEnabled(hostContext.hostOrganizationId) - : Promise.resolve(false), + resolveSimLinkOrganization(principal), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -174,47 +173,48 @@ async function WorkspaceContent({ 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': planModeEnabled, projects: projectsEnabled, - 'sim-links': simLinksEnabled, }} > - - - - - - - - - - - - - - - - } - initialSidebarCollapsed={initialSidebarCollapsed} - > - {children} - - - - - - + + + + + + + + + + + + + + + + + } + initialSidebarCollapsed={initialSidebarCollapsed} + > + {children} + + + + + + + diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index 5de69adbe69..f33fd8f230f 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -11,7 +11,6 @@ export interface WorkspaceFeatureFlags { 'mothership-computer-use'?: boolean 'mothership-memory-spaces'?: boolean projects?: boolean - 'sim-links'?: boolean } const FeatureFlagsContext = createContext(null) @@ -32,8 +31,3 @@ export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider') return flags[name] ?? false } - -/** The same flag for shared components that also render outside one, where every flag is off. */ -export function useOptionalFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean { - return useContext(FeatureFlagsContext)?.[name] ?? false -} diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index 2ebebf42144..12b5ee677b4 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -39,7 +39,7 @@ 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().max(2048).optional(), + 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/sim-links/validation.test.ts b/apps/sim/lib/sim-links/validation.test.ts index d410b1fd142..c2b9597837e 100644 --- a/apps/sim/lib/sim-links/validation.test.ts +++ b/apps/sim/lib/sim-links/validation.test.ts @@ -16,7 +16,8 @@ describe('suggestSimLinkName', () => { expect(simLinkNameSchema.safeParse(name).success).toBe(true) }) - it('suggests nothing when the title has no character a name can hold', () => { + 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 f29e4928162..c49a143f32a 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -36,14 +36,15 @@ export function toSimLinkName(input: string): string { /** * 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 the title has no character a name can hold, so the - * person types one instead. + * 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 { - return toSimLinkName(title.trim().toLowerCase()) + 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. */