From c51a62407719040c5e3882ff3f7ddc3ccd6783fd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 18:08:24 -0800 Subject: [PATCH 1/3] fix(sim-links): open the new-link form above the sources popover and suggest hyphenated names --- .../links/sim-link-organization-provider.tsx | 49 ++++++++++-- .../links/sim-link-shortcut.tsx | 11 ++- .../message-sources/message-sources.test.tsx | 77 +++++++++++++++++++ .../components/source-card/source-card.tsx | 17 ++-- apps/sim/lib/sim-links/validation.test.ts | 7 ++ apps/sim/lib/sim-links/validation.ts | 12 +-- 6 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx 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 index 9f9b63c9abc..03d1b30fc83 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-organization-provider.tsx @@ -1,8 +1,24 @@ 'use client' -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, type ReactNode, useContext, useState } from 'react' +import { suggestSimLinkName } from '@/lib/sim-links/validation' +import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal' -const SimLinkOrganizationContext = createContext(null) +interface SimLinkDraft { + url: string + title: string +} + +interface SimLinkScope { + organizationId: string | null + /** Opens the new-link form for `url`, with a name suggested from `title`. */ + openForm: (url: string, title: string) => void +} + +const SimLinkScopeContext = createContext({ + organizationId: null, + openForm: () => {}, +}) interface SimLinkOrganizationProviderProps { /** Where `sim/` resolves for this session, or null when it resolves nowhere. */ @@ -10,19 +26,40 @@ interface SimLinkOrganizationProviderProps { children: ReactNode } -/** Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer. */ +/** + * Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts + * the new-link form at the layout root. A form opened from a menu row inside a popover would + * otherwise render beneath it, since popovers stack above modals so they can open inside one; at + * the root the form takes focus outside the popover, which dismisses it. + */ export function SimLinkOrganizationProvider({ organizationId, children, }: SimLinkOrganizationProviderProps) { + const [draft, setDraft] = useState(null) return ( - + setDraft({ url, title }) }} + > {children} - + {draft && organizationId && ( + setDraft(null)} + /> + )} + ) } /** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */ export function useSimLinkOrganizationId(): string | null { - return useContext(SimLinkOrganizationContext) + return useContext(SimLinkScopeContext).organizationId +} + +/** Opens the layout-hosted new-link form. */ +export function useOpenSimLinkForm(): SimLinkScope['openForm'] { + return useContext(SimLinkScopeContext).openForm } 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 6fa73aa0dda..8f38068cc27 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx @@ -13,12 +13,15 @@ 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. */ + /** The new-link form while it is open, rendered inside the dialog that opened it. */ modal: ReactNode } -/** Opens the new-link form for `url`, prefilled with a name suggested from `title`. */ -export function useSimLinkCreator(url: string, title: string): SimLinkCreator { +/** + * A new-link form nested in the dialog that opens it, so it stacks above that dialog and keeps its + * focus trap. Menus and popovers use the layout-hosted form (`useOpenSimLinkForm`) instead. + */ +function useNestedSimLinkCreator(url: string, title: string): SimLinkCreator { const organizationId = useSimLinkOrganizationId() const [open, setOpen] = useState(false) return { @@ -112,7 +115,7 @@ interface SimLinkChipProps { /** 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) + const { organizationId, openCreate, modal } = useNestedSimLinkCreator(url, title) if (!organizationId) return null return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx new file mode 100644 index 00000000000..aa20b64b539 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx @@ -0,0 +1,77 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider' +import { MessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources' + +const source = { url: 'https://docs.example.test/guide', title: 'Setup guide' } + +/** Radix menus open on a primary-button pointer press, which jsdom's click() does not send. */ +function press(element: Element | null) { + if (!element) throw new Error('Expected an element to press') + element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('creating a Sim link from the sources popover', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ links: [], nextCursor: null })) + ) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + act(() => + root.render( + + + + + + ) + ) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + it('opens the form outside the popover and dismisses the popover, so the form is never beneath it', async () => { + act(() => document.querySelector('button[aria-label="1 source"]')?.click()) + const row = () => document.querySelector(`a[href="${source.url}"]`) + expect(row()).not.toBeNull() + + act(() => press(document.querySelector(`[aria-label="Actions for ${source.title}"]`))) + await settle() + const create = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent === 'Create Sim link' + ) + if (!(create instanceof HTMLElement)) throw new Error('Expected a Create Sim link item') + expect(create.getAttribute('aria-disabled')).not.toBe('true') + act(() => create.click()) + await settle() + + const form = [...document.querySelectorAll('[role="dialog"]')].find((dialog) => + dialog.textContent?.includes('New Sim link') + ) + expect(form).toBeDefined() + expect(document.querySelector('input[type="url"]')?.value).toBe(source.url) + expect(row()).toBeNull() + }) +}) 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 dc78f8b2abc..964117edfdd 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 @@ -21,9 +21,10 @@ 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' + useOpenSimLinkForm, + useSimLinkOrganizationId, +} from '@/app/o/[organizationId]/links/sim-link-organization-provider' +import { SimLinkMenuItem } from '@/app/o/[organizationId]/links/sim-link-shortcut' import { SourceIcon, sourceLabel, @@ -76,7 +77,8 @@ interface SourceActionsProps { function SourceActions({ source, onSummarize }: SourceActionsProps) { const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) - const simLink = useSimLinkCreator(source.url, sourceLabel(source)) + const simLinkOrganizationId = useSimLinkOrganizationId() + const openSimLinkForm = useOpenSimLinkForm() return ( <> @@ -101,16 +103,15 @@ function SourceActions({ source, onSummarize }: SourceActionsProps) { Copy link - {simLink.organizationId && ( + {simLinkOrganizationId && ( openSimLinkForm(source.url, sourceLabel(source))} /> )} - {simLink.modal} ) } diff --git a/apps/sim/lib/sim-links/validation.test.ts b/apps/sim/lib/sim-links/validation.test.ts index c2b9597837e..20c42e62f07 100644 --- a/apps/sim/lib/sim-links/validation.test.ts +++ b/apps/sim/lib/sim-links/validation.test.ts @@ -9,6 +9,13 @@ describe('suggestSimLinkName', () => { expect(simLinkNameSchema.safeParse(name).success).toBe(true) }) + it('joins words across colons, dots, and other name punctuation with a single hyphen', () => { + expect(suggestSimLinkName('Sim: Build and govern every AI agent')).toBe( + 'sim-build-and-govern-every-ai-agent' + ) + expect(suggestSimLinkName('docs.sim.ai @ v2 ~ draft')).toBe('docs-sim-ai-v2-draft') + }) + 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`) diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index c49a143f32a..4cf9e8364a0 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -35,16 +35,16 @@ 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 no letter or digit survives, so the person types one - * instead of being handed a name that is only punctuation. + * A starting name for a link made from a page's title: its letters and digits in lower case, every + * other run of characters (spaces, colons, dots, dashes) turned into one hyphen, cut to the name + * length limit. Empty when no letter or digit survives, so the person types one instead. */ export function suggestSimLinkName(title: string): string { - const name = toSimLinkName(title.trim().toLowerCase()) - .replace(/-{2,}/g, '-') + return title + .toLowerCase() + .replace(/[^a-z0-9]+/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. */ From 0ec3bdaf027047dfd0e0924449c0f5692e9e72e6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 18:18:33 -0800 Subject: [PATCH 2/3] test(sim-links): use the shared macrotask flush in the sources popover test --- .../components/message-sources/message-sources.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx index aa20b64b539..1fbeb76ec8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { flushMacrotask } from '@sim/testing' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,9 +18,7 @@ function press(element: Element | null) { } async function settle() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - }) + await act(flushMacrotask) } describe('creating a Sim link from the sources popover', () => { @@ -49,7 +48,6 @@ describe('creating a Sim link from the sources popover', () => { afterEach(() => { act(() => root.unmount()) container.remove() - vi.unstubAllGlobals() }) it('opens the form outside the popover and dismisses the popover, so the form is never beneath it', async () => { From be65087ab76bea34c19be0973f1de74e10196da0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 3 Nov 2026 18:28:37 -0800 Subject: [PATCH 3/3] test(sim-links): import the macrotask helper from its own module --- .../components/message-sources/message-sources.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx index 1fbeb76ec8f..ef15254ff19 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { act } from 'react' -import { flushMacrotask } from '@sim/testing' +import { flushMacrotask } from '@sim/testing/helpers/async' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'