Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 30 additions & 20 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -86,6 +88,7 @@ export default async function OrganizationLayout({
projectsEnabled,
simLinksEnabled,
peopleEnabled,
simLinkOrganizationId,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
Expand All @@ -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'

Expand All @@ -123,26 +131,28 @@ export default async function OrganizationLayout({
}}
>
<OrganizationProvider context={context}>
<GlobalCommandsProvider>
<WorkspaceViewport className='workspace-root'>
<ImpersonationBanner />
<SessionExpired />
<SettingsNavigationProvider>
<WorkspaceChrome
sidebar={
<OrganizationSidebar
benchmarkEnabled={benchmarkEnabled}
simLinksEnabled={simLinksEnabled}
peopleEnabled={peopleEnabled}
/>
}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
</SettingsNavigationProvider>
</WorkspaceViewport>
</GlobalCommandsProvider>
<SimLinkOrganizationProvider organizationId={simLinkOrganizationId}>
<GlobalCommandsProvider>
<WorkspaceViewport className='workspace-root'>
<ImpersonationBanner />
<SessionExpired />
<SettingsNavigationProvider>
<WorkspaceChrome
sidebar={
<OrganizationSidebar
benchmarkEnabled={benchmarkEnabled}
simLinksEnabled={simLinksEnabled}
peopleEnabled={peopleEnabled}
/>
}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
</WorkspaceChrome>
</SettingsNavigationProvider>
</WorkspaceViewport>
</GlobalCommandsProvider>
</SimLinkOrganizationProvider>
</OrganizationProvider>
</FeatureFlagsProvider>
</HydrationBoundary>
Expand Down
16 changes: 12 additions & 4 deletions apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Partial<Record<Field, string>>>({})
const variable = isVariableSimLinkUrl(draft.url)
const canManage = link?.canManage ?? true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { createContext, type ReactNode, useContext } from 'react'

const SimLinkOrganizationContext = createContext<string | null>(null)

interface SimLinkOrganizationProviderProps {
/** Where `sim/<name>` 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 (
<SimLinkOrganizationContext.Provider value={organizationId}>
{children}
</SimLinkOrganizationContext.Provider>
)
}

/** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */
export function useSimLinkOrganizationId(): string | null {
return useContext(SimLinkOrganizationContext)
}
123 changes: 123 additions & 0 deletions apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<SimLinkModal
organizationId={organizationId}
initialUrl={url}
initialName={suggestSimLinkName(title)}
onClose={() => 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 (
<DropdownMenuItem
onSelect={async () => {
if (await copyExisting()) toast.success(`Copied sim/${existing.name}`)
}}
>
<Link className='size-[14px]' />
Copy sim/{existing.name}
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem disabled={isPending} onSelect={onCreate}>
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
<Plus className='size-[14px]' />
Create Sim link
</DropdownMenuItem>
)
}

function ShortcutChip({ organizationId, url, onCreate }: SimLinkShortcutProps) {
const { existing, isPending, isError, copied, copyExisting } = useExistingSimLink(
organizationId,
url
)
if (isError) return null
if (existing) {
return (
<Chip leftIcon={copied ? Check : Link} onClick={() => void copyExisting()}>
{copied ? 'Copied!' : `Copy sim/${existing.name}`}
</Chip>
)
}
return (
<Chip leftIcon={Plus} disabled={isPending} onClick={onCreate}>
Create Sim link
</Chip>
)
}

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 (
<>
<ShortcutChip organizationId={organizationId} url={url} onCreate={openCreate} />
{modal}
</>
)
}
4 changes: 2 additions & 2 deletions apps/sim/app/o/[organizationId]/links/sim-links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -264,6 +265,11 @@ export function ShareModal({
</Chip>
),
},
{
custom: (
<SimLinkChip url={saved.url} title={fileName.replace(/\.[^.]+$/, '')} />
),
},
]
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Chip
leftIcon={copied ? Check : MoreHorizontal}
aria-label={copied ? 'Link copied' : `Actions for ${sourceLabel(source)}`}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{onSummarize && (
<DropdownMenuItem onSelect={() => onSummarize(source)}>
<Sparkles className='size-[14px]' />
Summarize
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Chip
leftIcon={copied ? Check : MoreHorizontal}
aria-label={copied ? 'Link copied' : `Actions for ${sourceLabel(source)}`}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{onSummarize && (
<DropdownMenuItem onSelect={() => onSummarize(source)}>
<Sparkles className='size-[14px]' />
Summarize
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={async () => {
if (!(await copy(source.url))) toast.error('Unable to copy link')
}}
>
<LinkIcon className='size-[14px]' />
Copy link
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={async () => {
if (!(await copy(source.url))) toast.error('Unable to copy link')
}}
>
<LinkIcon className='size-[14px]' />
Copy link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{simLink.organizationId && (
<SimLinkMenuItem
organizationId={simLink.organizationId}
url={source.url}
onCreate={simLink.openCreate}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
{simLink.modal}
</>
)
}

Expand Down
Loading
Loading