Skip to content

Commit 0ddd613

Browse files
committed
feat(sim-links): create or copy a Sim link from search results and file shares
1 parent d3ce970 commit 0ddd613

16 files changed

Lines changed: 279 additions & 30 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export default async function OrganizationLayout({
120120
'mothership-plan-mode': benchmarkEnabled,
121121
'mothership-memory-spaces': benchmarkEnabled,
122122
projects: projectsEnabled,
123+
'sim-links': simLinksEnabled,
123124
}}
124125
>
125126
<OrganizationProvider context={context}>

‎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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 { 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'
12+
import { useSimLinksForUrl } from '@/hooks/queries/sim-links'
13+
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+
26+
interface SimLinkCreator {
27+
/** Null where Sim links are off; render no shortcut then. */
28+
organizationId: string | null
29+
openCreate: () => void
30+
/** The new-link form while it is open; render it outside any menu, which unmounts on close. */
31+
modal: ReactNode
32+
}
33+
34+
/** Opens the new-link form for `url`, prefilled with a name suggested from `title`. */
35+
export function useSimLinkCreator(url: string, title: string): SimLinkCreator {
36+
const organizationId = useSimLinkOrganizationId()
37+
const [open, setOpen] = useState(false)
38+
return {
39+
organizationId,
40+
openCreate: () => setOpen(true),
41+
modal:
42+
open && organizationId ? (
43+
<SimLinkModal
44+
organizationId={organizationId}
45+
initialUrl={url}
46+
initialName={suggestSimLinkName(title)}
47+
onClose={() => setOpen(false)}
48+
/>
49+
) : null,
50+
}
51+
}
52+
53+
/** The first link that already opens `url`, and a copy of its address. */
54+
function useExistingSimLink(organizationId: string, url: string) {
55+
const { data, isPending } = useSimLinksForUrl(organizationId, url)
56+
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
57+
const existing = data?.at(0)
58+
const copyExisting = async () => {
59+
if (!existing) return false
60+
const done = await copy(simLinkUrl(window.location.origin, existing.name))
61+
if (!done) toast.error('Unable to copy Sim link')
62+
return done
63+
}
64+
return { existing, isPending, copied, copyExisting }
65+
}
66+
67+
interface SimLinkShortcutProps {
68+
organizationId: string
69+
url: string
70+
onCreate: () => void
71+
}
72+
73+
/**
74+
* 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.
76+
*/
77+
export function SimLinkMenuItem({ organizationId, url, onCreate }: SimLinkShortcutProps) {
78+
const { existing, isPending, copyExisting } = useExistingSimLink(organizationId, url)
79+
if (existing) {
80+
return (
81+
<DropdownMenuItem
82+
onSelect={async () => {
83+
if (await copyExisting()) toast.success(`Copied sim/${existing.name}`)
84+
}}
85+
>
86+
<Link className='size-[14px]' />
87+
Copy sim/{existing.name}
88+
</DropdownMenuItem>
89+
)
90+
}
91+
return (
92+
<DropdownMenuItem disabled={isPending} onSelect={onCreate}>
93+
<Plus className='size-[14px]' />
94+
Create Sim link
95+
</DropdownMenuItem>
96+
)
97+
}
98+
99+
function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) {
100+
const { existing, isPending, copied, copyExisting } = useExistingSimLink(organizationId, url)
101+
if (existing) {
102+
return (
103+
<Chip leftIcon={copied ? Check : Link} onClick={() => void copyExisting()}>
104+
{copied ? 'Copied!' : `Copy sim/${existing.name}`}
105+
</Chip>
106+
)
107+
}
108+
return (
109+
<Chip leftIcon={Plus} disabled={isPending} onClick={onCreate}>
110+
Create Sim link
111+
</Chip>
112+
)
113+
}
114+
115+
interface SimLinkChipProps {
116+
url: string
117+
/** The title a new link's name is suggested from. */
118+
title: string
119+
}
120+
121+
/** The same choice as a footer chip, for a dialog that already shows the address it shortens. */
122+
export function SimLinkChip({ url, title }: SimLinkChipProps) {
123+
const { organizationId, openCreate, modal } = useSimLinkCreator(url, title)
124+
if (!organizationId) return null
125+
return (
126+
<>
127+
<ShortcutChip organizationId={organizationId} url={url} onCreate={openCreate} />
128+
{modal}
129+
</>
130+
)
131+
}

‎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

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ 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'
1920
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
2021
import { ApplicationLoading } from '@/app/_shell/application-loading'
2122
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
@@ -134,6 +135,7 @@ async function WorkspaceContent({
134135
desktopExecutorRegistered,
135136
computerUseEnabled,
136137
projectsEnabled,
138+
simLinksEnabled,
137139
] = await Promise.all([
138140
cookies(),
139141
orgSettings,
@@ -153,6 +155,9 @@ async function WorkspaceContent({
153155
hasDesktopBackgroundExecutor(session.user.id),
154156
isComputerUseAvailable(hostContext.hostOrganizationId),
155157
isProjectsEnabled(),
158+
hostContext.hostOrganizationId
159+
? isSimLinksEnabled(hostContext.hostOrganizationId)
160+
: Promise.resolve(false),
156161
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
157162
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
158163
])
@@ -169,6 +174,7 @@ async function WorkspaceContent({
169174
'mothership-computer-use': computerUseEnabled,
170175
'mothership-plan-mode': planModeEnabled,
171176
projects: projectsEnabled,
177+
'sim-links': simLinksEnabled,
172178
}}
173179
>
174180
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>

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

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

1617
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
@@ -31,3 +32,8 @@ export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean {
3132
if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider')
3233
return flags[name] ?? false
3334
}
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/hooks/queries/sim-links.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
keepPreviousData,
33
useInfiniteQuery,
44
useMutation,
5+
useQuery,
56
useQueryClient,
67
} from '@tanstack/react-query'
78
import { requestJson } from '@/lib/api/client/request'
@@ -22,6 +23,8 @@ const simLinkKeys = {
2223
lists: (organizationId: string) => [...simLinkKeys.all, 'list', organizationId] as const,
2324
list: (organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection) =>
2425
[...simLinkKeys.lists(organizationId), search, sort, dir] as const,
26+
forUrl: (organizationId: string, url: string) =>
27+
[...simLinkKeys.lists(organizationId), 'url', url] as const,
2528
}
2629

2730
export function useSimLinks(
@@ -45,6 +48,24 @@ export function useSimLinks(
4548
})
4649
}
4750

51+
/**
52+
* Links whose destination is exactly `url`. Shares the list key prefix, so creating a link
53+
* refreshes it.
54+
*/
55+
export function useSimLinksForUrl(organizationId: string, url: string) {
56+
return useQuery({
57+
queryKey: simLinkKeys.forUrl(organizationId, url),
58+
queryFn: ({ signal }) =>
59+
requestJson(listSimLinksContract, {
60+
params: { id: organizationId },
61+
query: { url },
62+
signal,
63+
}),
64+
select: (page) => page.links,
65+
staleTime: SIM_LINKS_STALE_TIME,
66+
})
67+
}
68+
4869
function useInvalidateSimLinks(organizationId: string) {
4970
const queryClient = useQueryClient()
5071
return () => queryClient.invalidateQueries({ queryKey: simLinkKeys.lists(organizationId) })

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const linkResponse = z.object({ link: simLinkSchema })
3838
export const listSimLinksQuerySchema = z
3939
.object({
4040
search: z.string().max(200).optional(),
41+
/** Only links whose destination is exactly this URL, such as one shown in a search result. */
42+
url: z.string().max(2048).optional(),
4143
cursor: z.string().min(1).max(128).optional(),
4244
sort: z.enum(SIM_LINK_SORTS).optional(),
4345
dir: z.enum(SORT_DIRECTIONS).optional(),

0 commit comments

Comments
 (0)