Skip to content

Commit 04e6a89

Browse files
authored
feat(sim-links): create or copy a Sim link from search results and file shares (#9648)
* feat(sim-links): create or copy a Sim link from search results and file shares * fix(sim-links): scope result shortcuts to the organization sim/ resolves, hide on failed lookups
1 parent 0f49c83 commit 04e6a89

16 files changed

Lines changed: 361 additions & 87 deletions

File tree

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

Lines changed: 30 additions & 20 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

@@ -123,26 +131,28 @@ export default async function OrganizationLayout({
123131
}}
124132
>
125133
<OrganizationProvider context={context}>
126-
<GlobalCommandsProvider>
127-
<WorkspaceViewport className='workspace-root'>
128-
<ImpersonationBanner />
129-
<SessionExpired />
130-
<SettingsNavigationProvider>
131-
<WorkspaceChrome
132-
sidebar={
133-
<OrganizationSidebar
134-
benchmarkEnabled={benchmarkEnabled}
135-
simLinksEnabled={simLinksEnabled}
136-
peopleEnabled={peopleEnabled}
137-
/>
138-
}
139-
initialSidebarCollapsed={initialSidebarCollapsed}
140-
>
141-
{children}
142-
</WorkspaceChrome>
143-
</SettingsNavigationProvider>
144-
</WorkspaceViewport>
145-
</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>
146156
</OrganizationProvider>
147157
</FeatureFlagsProvider>
148158
</HydrationBoundary>

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@ interface SimLinkModalProps {
3838
link?: SimLink
3939
/** Prefills the name of a new link, such as one opened from an unknown sim/ link. */
4040
initialName?: string
41+
/** Prefills the destination of a new link, such as a search result's address. */
42+
initialUrl?: string
4143
}
4244

43-
function draftOf(link: SimLink | undefined, initialName = ''): SimLinkBody {
45+
function draftOf(link: SimLink | undefined, initialName = '', initialUrl = ''): SimLinkBody {
4446
return {
4547
name: link?.name ?? initialName,
46-
url: link?.url ?? '',
48+
url: link?.url ?? initialUrl,
4749
defaultUrl: link?.defaultUrl ?? null,
4850
description: link?.description ?? '',
4951
unlisted: link?.unlisted ?? false,
@@ -52,12 +54,18 @@ function draftOf(link: SimLink | undefined, initialName = ''): SimLinkBody {
5254
}
5355

5456
/** Mount per link (keyed) so each open starts from that link's saved fields. */
55-
export function SimLinkModal({ organizationId, onClose, link, initialName }: SimLinkModalProps) {
57+
export function SimLinkModal({
58+
organizationId,
59+
onClose,
60+
link,
61+
initialName,
62+
initialUrl,
63+
}: SimLinkModalProps) {
5664
const create = useCreateSimLink(organizationId)
5765
const update = useUpdateSimLink(organizationId)
5866
const mutation = link ? update : create
5967
const nameTaken = isApiClientError(mutation.error) && mutation.error.status === 409
60-
const [draft, setDraft] = useState(() => draftOf(link, initialName))
68+
const [draft, setDraft] = useState(() => draftOf(link, initialName, initialUrl))
6169
const [errors, setErrors] = useState<Partial<Record<Field, string>>>({})
6270
const variable = isVariableSimLinkUrl(draft.url)
6371
const canManage = link?.canManage ?? true
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+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use client'
2+
3+
import { type ReactNode, useState } from 'react'
4+
import { Chip, DropdownMenuItem, toast, useCopyToClipboard } from '@sim/emcn'
5+
import { Check, Link, Plus } from '@sim/emcn/icons'
6+
import { simLinkUrl } from '@/lib/navigation/paths'
7+
import { suggestSimLinkName } from '@/lib/sim-links/validation'
8+
import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
9+
import { useSimLinkOrganizationId } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
10+
import { useSimLinksForUrl } from '@/hooks/queries/sim-links'
11+
12+
interface SimLinkCreator {
13+
/** Null where Sim links are off; render no shortcut then. */
14+
organizationId: string | null
15+
openCreate: () => void
16+
/** The new-link form while it is open; render it outside any menu, which unmounts on close. */
17+
modal: ReactNode
18+
}
19+
20+
/** Opens the new-link form for `url`, prefilled with a name suggested from `title`. */
21+
export function useSimLinkCreator(url: string, title: string): SimLinkCreator {
22+
const organizationId = useSimLinkOrganizationId()
23+
const [open, setOpen] = useState(false)
24+
return {
25+
organizationId,
26+
openCreate: () => setOpen(true),
27+
modal:
28+
open && organizationId ? (
29+
<SimLinkModal
30+
organizationId={organizationId}
31+
initialUrl={url}
32+
initialName={suggestSimLinkName(title)}
33+
onClose={() => setOpen(false)}
34+
/>
35+
) : null,
36+
}
37+
}
38+
39+
/** The first link that already opens `url`, and a copy of its address. */
40+
function useExistingSimLink(organizationId: string, url: string) {
41+
const { data, isPending, isError } = useSimLinksForUrl(organizationId, url)
42+
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
43+
const existing = data?.at(0)
44+
const copyExisting = async () => {
45+
if (!existing) return false
46+
const done = await copy(simLinkUrl(window.location.origin, existing.name))
47+
if (!done) toast.error('Unable to copy Sim link')
48+
return done
49+
}
50+
return { existing, isPending, isError, copied, copyExisting }
51+
}
52+
53+
interface SimLinkShortcutProps {
54+
organizationId: string
55+
url: string
56+
onCreate: () => void
57+
}
58+
59+
/**
60+
* The Sim link entry of an actions menu: copies the link that already opens this address, so a
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.
63+
*/
64+
export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortcutProps) {
65+
const { existing, isPending, isError, copyExisting } = useExistingSimLink(organizationId, url)
66+
if (isError) return null
67+
if (existing) {
68+
return (
69+
<DropdownMenuItem
70+
onSelect={async () => {
71+
if (await copyExisting()) toast.success(`Copied sim/${existing.name}`)
72+
}}
73+
>
74+
<Link className='size-[14px]' />
75+
Copy sim/{existing.name}
76+
</DropdownMenuItem>
77+
)
78+
}
79+
return (
80+
<DropdownMenuItem disabled={isPending} onSelect={onCreate}>
81+
<Plus className='size-[14px]' />
82+
Create Sim link
83+
</DropdownMenuItem>
84+
)
85+
}
86+
87+
function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) {
88+
const { existing, isPending, isError, copied, copyExisting } = useExistingSimLink(
89+
organizationId,
90+
url
91+
)
92+
if (isError) return null
93+
if (existing) {
94+
return (
95+
<Chip leftIcon={copied ? Check : Link} onClick={() => void copyExisting()}>
96+
{copied ? 'Copied!' : `Copy sim/${existing.name}`}
97+
</Chip>
98+
)
99+
}
100+
return (
101+
<Chip leftIcon={Plus} disabled={isPending} onClick={onCreate}>
102+
Create Sim link
103+
</Chip>
104+
)
105+
}
106+
107+
interface SimLinkChipProps {
108+
url: string
109+
/** The title a new link's name is suggested from. */
110+
title: string
111+
}
112+
113+
/** The same choice as a footer chip, for a dialog that already shows the address it shortens. */
114+
export function SimLinkChip({ url, title }: SimLinkChipProps) {
115+
const { organizationId, openCreate, modal } = useSimLinkCreator(url, title)
116+
if (!organizationId) return null
117+
return (
118+
<>
119+
<ShortcutChip organizationId={organizationId} url={url} onCreate={openCreate} />
120+
{modal}
121+
</>
122+
)
123+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Link } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { useQueryStates } from 'nuqs'
88
import type { SimLink } from '@/lib/api/contracts/sim-links'
9-
import { simLinkPath } from '@/lib/navigation/paths'
9+
import { simLinkPath, simLinkUrl } from '@/lib/navigation/paths'
1010
import { toSimLinkName } from '@/lib/sim-links/validation'
1111
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1212
import {
@@ -62,7 +62,7 @@ function openLink(name: string) {
6262

6363
async function copyLink(name: string) {
6464
try {
65-
await writeTextToClipboard(new URL(simLinkPath(name), window.location.origin).href)
65+
await writeTextToClipboard(simLinkUrl(window.location.origin, name))
6666
} catch (error) {
6767
logger.error('Failed to copy Sim link', { error })
6868
}

‎apps/sim/app/workspace/[workspaceId]/files/components/share-modal/share-modal.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { GeneratedPasswordInput } from '@/components/ui'
2020
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
2121
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
2222
import { validateAllowlistEntry } from '@/lib/messaging/email/validation'
23+
import { SimLinkChip } from '@/app/o/[organizationId]/links/sim-link-shortcut'
2324
import { useFileShare, useUpsertFileShare } from '@/hooks/queries/public-shares'
2425
import { usePermissionConfig } from '@/hooks/use-permission-config'
2526

@@ -264,6 +265,11 @@ export function ShareModal({
264265
</Chip>
265266
),
266267
},
268+
{
269+
custom: (
270+
<SimLinkChip url={saved.url} title={fileName.replace(/\.[^.]+$/, '')} />
271+
),
272+
},
267273
]
268274
: undefined
269275
}

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx‎

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import { Check, Link as LinkIcon, MoreHorizontal, Sparkles } from '@sim/emcn/ico
2020
import { formatDate } from '@sim/utils/formatting'
2121
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
2222
import { inter } from '@/app/_styles/fonts/inter/inter'
23+
import {
24+
SimLinkMenuItem,
25+
useSimLinkCreator,
26+
} from '@/app/o/[organizationId]/links/sim-link-shortcut'
2327
import {
2428
SourceIcon,
2529
sourceLabel,
@@ -72,31 +76,42 @@ interface SourceActionsProps {
7276

7377
function SourceActions({ source, onSummarize }: SourceActionsProps) {
7478
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
79+
const simLink = useSimLinkCreator(source.url, sourceLabel(source))
7580
return (
76-
<DropdownMenu>
77-
<DropdownMenuTrigger asChild>
78-
<Chip
79-
leftIcon={copied ? Check : MoreHorizontal}
80-
aria-label={copied ? 'Link copied' : `Actions for ${sourceLabel(source)}`}
81-
/>
82-
</DropdownMenuTrigger>
83-
<DropdownMenuContent align='end'>
84-
{onSummarize && (
85-
<DropdownMenuItem onSelect={() => onSummarize(source)}>
86-
<Sparkles className='size-[14px]' />
87-
Summarize
81+
<>
82+
<DropdownMenu>
83+
<DropdownMenuTrigger asChild>
84+
<Chip
85+
leftIcon={copied ? Check : MoreHorizontal}
86+
aria-label={copied ? 'Link copied' : `Actions for ${sourceLabel(source)}`}
87+
/>
88+
</DropdownMenuTrigger>
89+
<DropdownMenuContent align='end'>
90+
{onSummarize && (
91+
<DropdownMenuItem onSelect={() => onSummarize(source)}>
92+
<Sparkles className='size-[14px]' />
93+
Summarize
94+
</DropdownMenuItem>
95+
)}
96+
<DropdownMenuItem
97+
onSelect={async () => {
98+
if (!(await copy(source.url))) toast.error('Unable to copy link')
99+
}}
100+
>
101+
<LinkIcon className='size-[14px]' />
102+
Copy link
88103
</DropdownMenuItem>
89-
)}
90-
<DropdownMenuItem
91-
onSelect={async () => {
92-
if (!(await copy(source.url))) toast.error('Unable to copy link')
93-
}}
94-
>
95-
<LinkIcon className='size-[14px]' />
96-
Copy link
97-
</DropdownMenuItem>
98-
</DropdownMenuContent>
99-
</DropdownMenu>
104+
{simLink.organizationId && (
105+
<SimLinkMenuItem
106+
organizationId={simLink.organizationId}
107+
url={source.url}
108+
onCreate={simLink.openCreate}
109+
/>
110+
)}
111+
</DropdownMenuContent>
112+
</DropdownMenu>
113+
{simLink.modal}
114+
</>
100115
)
101116
}
102117

0 commit comments

Comments
 (0)