From 2f5a99d6e47a7ae4adb12cbd8f2f2aa59a245afb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:08:12 -0700 Subject: [PATCH 1/2] improvement(sim-links): pass queries through, trim pasted punctuation, and suggest links from the address bar --- apps/docs/content/docs/search/sim-links.mdx | 6 +- apps/extension/README.md | 1 + apps/extension/scripts/build.ts | 3 +- apps/extension/src/background.ts | 2 + apps/extension/src/omnibox.ts | 106 ++++++++++++++++++ .../app/api/sim-links/suggestions/route.ts | 24 ++++ apps/sim/app/sim/[[...path]]/page.tsx | 42 ++++--- apps/sim/lib/api/contracts/sim-links.ts | 12 ++ apps/sim/lib/organizations/surface.ts | 21 +++- .../lib/sim-links/application/operations.ts | 8 ++ .../lib/sim-links/application/organization.ts | 15 +++ .../lib/sim-links/application/use-cases.ts | 35 +++++- apps/sim/lib/sim-links/destination.test.ts | 58 +++++++++- apps/sim/lib/sim-links/destination.ts | 27 +++-- apps/sim/lib/sim-links/repository.ts | 26 ++++- apps/sim/lib/sim-links/validation.ts | 27 ++++- .../browser-extension-protocol/src/index.ts | 10 ++ 17 files changed, 383 insertions(+), 40 deletions(-) create mode 100644 apps/extension/src/omnibox.ts create mode 100644 apps/sim/app/api/sim-links/suggestions/route.ts create mode 100644 apps/sim/lib/sim-links/application/organization.ts diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index e9d5d553eba..bf26c207846 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -15,7 +15,9 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` ## Open links from the address bar -Add Sim as a Chrome site search shortcut to type `sim handbook` instead: +With the Sim Chrome extension, type `sim/handbook` in the address bar. To find a link, type `sim`, press **Space**, and start typing its name: matching links appear as you type, most opened first. + +Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead: 1. Open Chrome **Settings → Search engine → Manage search engines and site search**. 2. Next to **Site search**, select **Add**. @@ -33,7 +35,7 @@ To share a link, right-click it and select **Copy link**. ## Add values to a link -Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`. +Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`. A query is passed along too, so `sim/docs?lang=fr` opens `https://docs.example.com/?lang=fr`. To place a value elsewhere, put `{*}` in the destination URL: diff --git a/apps/extension/README.md b/apps/extension/README.md index 7f7c4166efa..b9ba156c936 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -27,6 +27,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app - Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`. - Typing `sim/` in the address bar opens the organization's [Sim link](https://docs.sim.ai/search/sim-links). A single static redirect rule rewrites top-level navigations to `http(s)://sim/*` to `/sim/*` with the path and query unchanged. The `sim` host permission exists only for that rule: every navigation to that host is redirected before it loads, and no other request is matched or observed. An organization that already serves an intranet host named `sim` should not install this build, since its links would be redirected to Sim. +- The `sim` address-bar keyword (`sim`, then Space) lists matching Sim links while you type and opens what you enter through the same resolver. It is the extension's only network request: the background worker sends the typed text, and nothing else, to `/api/sim-links/suggestions` with the signed-in session, and the extension's content security policy allows connections to that origin alone. Unlisted links are never suggested. - Optional HTTP/HTTPS site access is requested only by a menu action naming that site. Chrome's `activeTab` access is temporary and does not follow arbitrary new sites. - Page capture runs on demand in Chrome's isolated world. It excludes editable fields, inputs, hidden elements, scripts, and embedded documents. It reads only the top frame. PDF viewers and canvas documents may have no accessible text. - Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation. diff --git a/apps/extension/scripts/build.ts b/apps/extension/scripts/build.ts index 072321b0ca0..80b47369ec6 100644 --- a/apps/extension/scripts/build.ts +++ b/apps/extension/scripts/build.ts @@ -125,6 +125,7 @@ await writeFile( }, optional_host_permissions: ['https://*/*', 'http://*/*'], background: { service_worker: 'background.js' }, + omnibox: { keyword: 'sim' }, side_panel: { default_path: 'sidepanel.html' }, action: { default_title: 'Open Sim' }, icons: { 16: 'icon-16.png', 32: 'icon-32.png', 128: 'icon-128.png' }, @@ -135,7 +136,7 @@ await writeFile( }, }, content_security_policy: { - extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src 'none'; object-src 'none'; base-uri 'none'`, + extension_pages: `default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; frame-src ${origin.origin}; connect-src ${origin.origin}; object-src 'none'; base-uri 'none'`, }, incognito: 'not_allowed', ...(key ? { key } : {}), diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 27db2cf08eb..c6585bc53dc 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config' import { captureIntentSchema } from '@/intent' +import { registerSimLinkOmnibox } from '@/omnibox' import { digestUrl } from '@/utils' const logger = createLogger('BrowserExtension') @@ -50,6 +51,7 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') { .catch(() => logger.warn('Could not attach this page; the side panel remains available')) } +registerSimLinkOmnibox() chrome.runtime.onInstalled.addListener(() => { void configure().catch(() => logger.warn('Could not initialize extension controls')) }) diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts new file mode 100644 index 00000000000..6dd8e452cd5 --- /dev/null +++ b/apps/extension/src/omnibox.ts @@ -0,0 +1,106 @@ +import { + SIM_LINK_SUGGESTIONS_PATH, + simLinkSuggestionsSchema, +} from '@sim/browser-extension-protocol' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { SIM_ORIGIN } from '@/config' + +const logger = createLogger('BrowserExtensionOmnibox') + +/** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */ +const SUGGEST_DELAY_MS = 120 + +/** What was typed after the `sim` keyword, whether or not `sim/` was typed too. */ +function typedSimLink(text: string): string { + return text.trim().replace(/^sim\//i, '') +} + +/** The name part, which alone decides the suggestions: `jira/ABC-1?x` suggests from `jira`. */ +function typedName(text: string): string { + return typedSimLink(text).split(/[/?]/)[0].toLowerCase() +} + +/** Suggestion text is XML, where these five characters are markup. */ +function escapeXml(text: string): string { + return text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`) +} + +/** + * The resolver address for what was typed after the `sim` keyword, written the way it is typed + * in the address bar: `jira/ABC-1?tab=all` (with or without a leading `sim/`) opens + * `sim/jira/ABC-1?tab=all`. Each `/` separates a value, and a value may contain spaces. + */ +function simLinkUrl(text: string): string { + const typed = typedSimLink(text) + const queryStart = typed.indexOf('?') + const path = queryStart === -1 ? typed : typed.slice(0, queryStart) + const query = queryStart === -1 ? '' : new URLSearchParams(typed.slice(queryStart + 1)).toString() + const segments = path.split('/').filter(Boolean).map(encodeURIComponent) + return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}` +} + +async function fetchSuggestions( + name: string, + signal: AbortSignal +): Promise { + const response = await fetch( + `${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`, + { credentials: 'include', signal } + ) + // Signed out, or Sim links are unavailable: the typed link still opens through the resolver. + if (!response.ok) return [] + const { links } = simLinkSuggestionsSchema.parse(await response.json()) + return links.map((link) => ({ + content: link.name, + description: `sim/${escapeXml(link.name)} ${escapeXml(link.description ?? link.url)}`, + })) +} + +/** + * The `sim` address-bar keyword: `sim`, then Space, lists matching Sim links as you type, and + * Enter opens what was typed through the resolver, which sends a missing link to its create form. + */ +export function registerSimLinkOmnibox() { + let pending: AbortController | null = null + /** Suggestions by typed name for the current keyword session, so retyping and editing values reuse them. */ + const suggestionsByName = new Map() + + chrome.omnibox.onInputStarted.addListener(() => suggestionsByName.clear()) + + chrome.omnibox.onInputChanged.addListener((text, suggest) => { + pending?.abort() + chrome.omnibox.setDefaultSuggestion({ + description: `Open sim/${escapeXml(typedSimLink(text))}`, + }) + const name = typedName(text) + const known = suggestionsByName.get(name) + if (known) { + suggest(known) + return + } + const controller = new AbortController() + pending = controller + void (async () => { + await sleep(SUGGEST_DELAY_MS) + if (controller.signal.aborted) return + const suggestions = await fetchSuggestions(name, controller.signal) + suggestionsByName.set(name, suggestions) + if (!controller.signal.aborted) suggest(suggestions) + })().catch(() => { + if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions') + }) + }) + + chrome.omnibox.onInputCancelled.addListener(() => pending?.abort()) + + chrome.omnibox.onInputEntered.addListener((text, disposition) => { + pending?.abort() + const url = simLinkUrl(text) + const opened = + disposition === 'currentTab' + ? chrome.tabs.update({ url }) + : chrome.tabs.create({ url, active: disposition === 'newForegroundTab' }) + void opened.catch(() => logger.warn('Could not open the Sim link')) + }) +} diff --git a/apps/sim/app/api/sim-links/suggestions/route.ts b/apps/sim/app/api/sim-links/suggestions/route.ts new file mode 100644 index 00000000000..d7535a6cc71 --- /dev/null +++ b/apps/sim/app/api/sim-links/suggestions/route.ts @@ -0,0 +1,24 @@ +import { suggestSimLinksContract } from '@/lib/api/contracts/sim-links' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { suggestSimLinks } from '@/lib/sim-links/application/use-cases' + +export const GET = defineInternalJsonRoute({ + contract: suggestSimLinksContract, + operation: suggestSimLinks.operation, + useCase: suggestSimLinks, + auth: internalSessionAuth, + rateLimit: internalRateLimits.user({ + bucketName: 'sim-link-suggestions', + // The address bar asks again as each character is typed. + config: { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ query }) => ({ query: query.q }), + present: (links) => ({ links }), +}) diff --git a/apps/sim/app/sim/[[...path]]/page.tsx b/apps/sim/app/sim/[[...path]]/page.tsx index 209123a1cd5..b29066a2c27 100644 --- a/apps/sim/app/sim/[[...path]]/page.tsx +++ b/apps/sim/app/sim/[[...path]]/page.tsx @@ -2,12 +2,10 @@ import { ChipLink, StatusPageContent } from '@sim/emcn' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { getActiveOrganizationId } from '@/lib/auth/session-response' import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths' -import { resolveOrganizationLanding } from '@/lib/organizations/surface' +import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases' -import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' -import { simLinkNameSchema } from '@/lib/sim-links/validation' +import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { LogoShell } from '@/app/(landing)/components/logo-shell' import { serializeSimLinkView } from '@/app/o/[organizationId]/links/search-params' @@ -19,6 +17,7 @@ export const metadata: Metadata = { interface SimLinkPageProps { params: Promise<{ path?: string[] }> + searchParams: Promise> } /** @@ -32,6 +31,15 @@ function decodeSimLinkPath(path: string[]): string[] { return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments } +/** The typed `?…` query, rebuilt from Next's parsed params, which group repeated keys. */ +function toQueryString(searchParams: Record): string { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(searchParams)) { + for (const item of [value ?? []].flat()) query.append(key, item) + } + return query.toString() +} + /** * Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path * can't put its own text on this page. @@ -47,32 +55,34 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name: } /** - * Where `sim//` lands: the viewer's organization link's destination, + * Where `sim//?` lands: the viewer's organization link's destination, * or a page that offers to create the link when nobody has yet. */ -export default async function SimLinkPage({ params }: SimLinkPageProps) { - const { path = [] } = await params +export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) { + const [{ path = [] }, query] = await Promise.all([params, searchParams.then(toQueryString)]) const segments = decodeSimLinkPath(path) const session = await getSession() if (!session?.user) redirect( buildAuthCrossLink('/login', { - callbackUrl: simLinkPath(...segments), + callbackUrl: `${simLinkPath(...segments)}${query ? `?${query}` : ''}`, isInviteFlow: false, }) ) - const organizationId = await resolveOrganizationLanding( - session.user.id, - getActiveOrganizationId(session) - ) - if (!organizationId || !(await isSimLinksEnabled(organizationId))) notFound() + const principal = { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + } as const + const organizationId = await resolveSimLinkOrganization(principal) + if (!organizationId) notFound() const library = organizationRoutes(organizationId).simLinks - const [name, ...values] = segments + const { name, values } = parseTypedSimLink(segments) if (!name) redirect(library) const result = await resolveSimLink.execute({ - principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - input: { organizationId, name, values }, + principal, + input: { organizationId, name, values, query }, }) if (result.kind === 'redirect') redirect(result.url) diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index c7d37aef390..98de8a146ac 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -1,3 +1,7 @@ +import { + SIM_LINK_SUGGESTIONS_PATH, + simLinkSuggestionsSchema, +} from '@sim/browser-extension-protocol' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { organizationIdSchema } from '@/lib/api/contracts/primitives' @@ -74,3 +78,11 @@ export const deleteSimLinkContract = defineRouteContract({ params: linkParams, response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, }) + +/** The browser extension's address-bar suggestions, from the organization `sim/` opens in. */ +export const suggestSimLinksContract = defineRouteContract({ + method: 'GET', + path: SIM_LINK_SUGGESTIONS_PATH, + query: z.object({ q: z.string().max(200).default('') }), + response: { mode: 'json', schema: simLinkSuggestionsSchema }, +}) diff --git a/apps/sim/lib/organizations/surface.ts b/apps/sim/lib/organizations/surface.ts index 8608fab768b..c97b8cf1807 100644 --- a/apps/sim/lib/organizations/surface.ts +++ b/apps/sim/lib/organizations/surface.ts @@ -1,7 +1,8 @@ import { cache } from 'react' +import type { SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' -import { member, organization } from '@sim/db/schema' -import { asc, count, eq } from 'drizzle-orm' +import { member, organization, session } from '@sim/db/schema' +import { and, asc, count, eq } from 'drizzle-orm' import { getOrganizationSettingsFeatures, type OrganizationSettingsFeatures, @@ -172,3 +173,19 @@ export async function resolveOrganizationLanding( memberships.some((row) => row.organizationId === activeOrganizationId) return isActiveMember ? activeOrganizationId : memberships[0].organizationId } + +/** + * {@link resolveOrganizationLanding} for a session known only by its principal, reading the + * active organization stored on the session, so an API call lands where a page load would. + */ +export async function resolveSessionOrganizationLanding({ + userId, + sessionId, +}: SessionPrincipal): Promise { + const [row] = await db + .select({ activeOrganizationId: session.activeOrganizationId }) + .from(session) + .where(and(eq(session.id, sessionId), eq(session.userId, userId))) + .limit(1) + return resolveOrganizationLanding(userId, row?.activeOrganizationId ?? null) +} diff --git a/apps/sim/lib/sim-links/application/operations.ts b/apps/sim/lib/sim-links/application/operations.ts index 2f73dea4783..2ae93c4d499 100644 --- a/apps/sim/lib/sim-links/application/operations.ts +++ b/apps/sim/lib/sim-links/application/operations.ts @@ -1,3 +1,4 @@ +import { defineOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' const policy = { minimumRole: 'member', principalKinds: ['session'] } as const @@ -34,4 +35,11 @@ export const simLinkOperations = { ...policy, capability: 'none', }), + /** Not organization-scoped: it serves whichever organization `sim/` opens in for the session. */ + // permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability. + suggest: defineOperation({ + id: 'sim_links.suggest', + principalKinds: ['session'], + capability: 'none', + }), } as const diff --git a/apps/sim/lib/sim-links/application/organization.ts b/apps/sim/lib/sim-links/application/organization.ts new file mode 100644 index 00000000000..21a936be39f --- /dev/null +++ b/apps/sim/lib/sim-links/application/organization.ts @@ -0,0 +1,15 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { resolveSessionOrganizationLanding } from '@/lib/organizations/surface' +import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' + +/** + * The organization whose links `sim/` opens for this session: where the session lands, or `null` + * when it belongs to none or Sim links are off there. Following a link and suggesting one both + * read this, so a suggestion always resolves where it is followed. + */ +export async function resolveSimLinkOrganization( + principal: SessionPrincipal +): Promise { + const organizationId = await resolveSessionOrganizationLanding(principal) + return organizationId && (await isSimLinksEnabled(organizationId)) ? organizationId : null +} diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index 9266bb40d94..6b7ab865042 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' @@ -10,13 +11,21 @@ import { import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application/authorized-workspace-use-case' import type { OrganizationMembershipContext } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbTransaction } from '@/lib/db/types' import { simLinkOperations } from '@/lib/sim-links/application/operations' +import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization' import { resolveSimLinkTarget, type SimLinkTarget } from '@/lib/sim-links/destination' import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag' import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository' import * as repository from '@/lib/sim-links/repository' -import type { SimLinkFields, SimLinkSort } from '@/lib/sim-links/validation' +import { + normalizeSimLinkName, + parseTypedSimLink, + type SimLinkFields, + type SimLinkSort, + stripSimLinkPrefix, +} from '@/lib/sim-links/validation' import type { SortDirection } from '@/lib/url-state' const logger = createLogger('SimLinks') @@ -228,6 +237,7 @@ async function countVisit(organizationId: string, linkId: string): Promise /** * Unlisted links resolve for every member: unlisting hides a link from browsing, not from use. * Only a redirect counts as a visit; a link opened without the values it needs does not. + * `query` is the typed `?…` string, carried to the destination. */ export const resolveSimLink = defineAuthorizedOrganizationUseCase({ operation: simLinkOperations.resolve, @@ -235,12 +245,31 @@ export const resolveSimLink = defineAuthorizedOrganizationUseCase({ async execute({ input, }: OrganizationUseCaseContext< - OrganizationInput & { name: string; values: readonly string[] } + OrganizationInput & { name: string; values: readonly string[]; query: string } >): Promise { const link = await repository.findSimLinkDestination(input.organizationId, input.name) if (!link) return { kind: 'not_found' } - const target = resolveSimLinkTarget(link, input.values) + const target = resolveSimLinkTarget(link, input.values, new URLSearchParams(input.query)) if (target.kind === 'redirect') await countVisit(input.organizationId, link.id) return target }, }) + +/** The name in what was typed after the `sim` keyword: `sim/jira/ABC-1?x` suggests from `jira`. */ +function typedNamePrefix(query: string): string { + const [path = ''] = stripSimLinkPrefix(query.trim()).split('?') + return normalizeSimLinkName(parseTypedSimLink(path.split('/')).name) +} + +/** + * Address-bar suggestions from the organization `sim/` opens in for this session, so every + * suggestion resolves where it is followed. Nothing is suggested where Sim links are off. + */ +export const suggestSimLinks = defineAuthorizedCredentialUserUseCase({ + operation: simLinkOperations.suggest, + async execute({ principal, input }: { principal: SessionPrincipal; input: { query: string } }) { + const organizationId = await resolveSimLinkOrganization(principal) + if (!organizationId) return [] + return repository.suggestSimLinks(organizationId, typedNamePrefix(input.query)) + }, +}) diff --git a/apps/sim/lib/sim-links/destination.test.ts b/apps/sim/lib/sim-links/destination.test.ts index a9909c00d4a..19e5f826477 100644 --- a/apps/sim/lib/sim-links/destination.test.ts +++ b/apps/sim/lib/sim-links/destination.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { resolveSimLinkTarget } from '@/lib/sim-links/destination' -import { simLinkFieldsSchema } from '@/lib/sim-links/validation' +import { parseTypedSimLink, simLinkFieldsSchema } from '@/lib/sim-links/validation' const fields = { name: 'docs', @@ -14,8 +14,13 @@ function acceptsDestination(url: string, defaultUrl: string | null = null): bool return simLinkFieldsSchema.safeParse({ ...fields, url, defaultUrl }).success } -function redirectUrl(url: string, values: string[], defaultUrl: string | null = null): string { - const target = resolveSimLinkTarget({ url, defaultUrl }, values) +function redirectUrl( + url: string, + values: string[], + defaultUrl: string | null = null, + query?: string +): string { + const target = resolveSimLinkTarget({ url, defaultUrl }, values, new URLSearchParams(query)) if (target.kind !== 'redirect') throw new Error(`Expected a redirect, got ${target.kind}`) return target.url } @@ -130,4 +135,51 @@ describe('Sim link destinations', () => { ) } }) + + it.each([ + ['https://ex.com/p', [], null, 'a=1&b=two words', 'https://ex.com/p?a=1&b=two+words'], + ['https://ex.com/p?x=1#top', ['y'], null, 'x=2', 'https://ex.com/p/y?x=1&x=2#top'], + [ + 'https://ex.com/search?q={*}', + ['links'], + null, + 'page=2', + 'https://ex.com/search?q=links&page=2', + ], + ['https://ex.com/{*}', [], 'https://ex.com/home', 'tab=open', 'https://ex.com/home?tab=open'], + ['https://ex.com/s?q=a%20b&p=x/y', [], null, 'n=1', 'https://ex.com/s?q=a%20b&p=x/y&n=1'], + ['https://ex.com/p', [], null, 'q=a#b', 'https://ex.com/p?q=a%23b'], + ['https://ex.com/p', [], null, '', 'https://ex.com/p'], + ])('carries the typed query to %s', (url, values, defaultUrl, query, expected) => { + expect(redirectUrl(url, values, defaultUrl, query)).toBe(expected) + }) +}) + +describe('parseTypedSimLink', () => { + it.each([ + ['onboarding).', 'onboarding'], + ['onboarding,', 'onboarding'], + ['onboarding”', 'onboarding'], + ["onboarding'", 'onboarding'], + ['onboarding]>', 'onboarding'], + ['q4-goals?!', 'q4-goals'], + ])('drops the prose punctuation pasted after %s', (name, trimmed) => { + expect(parseTypedSimLink([name])).toEqual({ name: trimmed, values: [] }) + }) + + it.each(['onboarding', 'q4-goals', 'a~b', 'x_'])('keeps %s, which has none', (name) => { + expect(parseTypedSimLink([name]).name).toBe(name) + }) + + it('keeps a name that is nothing but punctuation, so it fails validation as typed', () => { + expect(parseTypedSimLink(['...']).name).toBe('...') + }) + + it('keeps a name and its values verbatim when values follow, since a value may end in anything', () => { + expect(parseTypedSimLink(['jira', 'ABC-1).'])).toEqual({ name: 'jira', values: ['ABC-1).'] }) + }) + + it('reads no name from an empty path', () => { + expect(parseTypedSimLink([])).toEqual({ name: '', values: [] }) + }) }) diff --git a/apps/sim/lib/sim-links/destination.ts b/apps/sim/lib/sim-links/destination.ts index 439946d3e62..358d2d36a09 100644 --- a/apps/sim/lib/sim-links/destination.ts +++ b/apps/sim/lib/sim-links/destination.ts @@ -24,33 +24,39 @@ function encodeSegments(values: readonly string[]): string { return values.map(encodeURIComponent).join('/') } -/** Values fill only what follows a closed authority, so leaving `origin` is a broken invariant. */ -function redirectWithin(origin: string, url: string): SimLinkTarget { +/** + * Values fill only what follows a closed authority, so leaving `origin` is a broken invariant. + * The typed query follows the destination's own parameters, whose encoding is kept as stored. + */ +function redirectWithin(origin: string, url: string, query: URLSearchParams): SimLinkTarget { const parsed = parseHttpUrl(url) if (parsed?.origin !== origin) throw new Error('Sim link resolved outside its fixed origin') + const typed = query.toString() + if (typed) parsed.search = parsed.search ? `${parsed.search}&${typed}` : typed return { kind: 'redirect', url: parsed.toString() } } /** - * Where `sim//` leads. A plain link appends the values to its path. + * Where `sim//?` leads. A plain link appends the values to its path. * A variable link fills each `{*}` in order, the last taking every remaining value, - * and opens its default destination when given none. Every redirect stays on the - * stored destination's origin. + * and opens its default destination when given none. The query carries over to whichever + * destination opens, and every redirect stays on the stored destination's origin. */ export function resolveSimLinkTarget( link: SimLinkDestination, - values: readonly string[] + values: readonly string[], + query: URLSearchParams = new URLSearchParams() ): SimLinkTarget { const origin = simLinkOrigin(link.url) if (!origin) throw new Error('Sim link destination has no fixed http(s) origin') if (values.some(isDotSegment)) return { kind: 'invalid_values' } if (!isVariableSimLinkUrl(link.url)) { - if (!values.length) return redirectWithin(origin, link.url) + if (!values.length) return redirectWithin(origin, link.url, query) const url = new URL(link.url) url.pathname = `${url.pathname.replace(/\/+$/, '')}/${encodeSegments(values)}` - return redirectWithin(origin, url.toString()) + return redirectWithin(origin, url.toString(), query) } - if (!values.length && link.defaultUrl) return redirectWithin(origin, link.defaultUrl) + if (!values.length && link.defaultUrl) return redirectWithin(origin, link.defaultUrl, query) const parts = link.url.split(SIM_LINK_VARIABLE) const slots = parts.length - 1 if (values.length < slots) return { kind: 'missing_values', required: slots } @@ -60,6 +66,7 @@ export function resolveSimLinkTarget( ] return redirectWithin( origin, - parts.reduce((url, part, index) => url + fills[index - 1] + part) + parts.reduce((url, part, index) => url + fills[index - 1] + part), + query ) } diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index 9512a4d4b57..17023e545d8 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { simLink, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { escapeLikePattern } from '@sim/utils/string' -import { and, asc, desc, eq, gt, ilike, lt, or, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gt, ilike, like, lt, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { normalizeSimLinkName, @@ -134,6 +134,30 @@ export async function getSimLink( return row ? toRecord(row) : null } +const SUGGESTION_LIMIT = 6 + +/** + * Listed links whose name starts with `namePrefix` (normalized), most visited first; the most + * visited overall when it is empty. Unlisted links never appear: suggesting is browsing. + */ +export async function suggestSimLinks( + organizationId: string, + namePrefix: string +): Promise<{ name: string; description: string | null; url: string }[]> { + return db + .select({ name: simLink.name, description: simLink.description, url: simLink.url }) + .from(simLink) + .where( + and( + eq(simLink.organizationId, organizationId), + eq(simLink.unlisted, false), + namePrefix ? like(simLink.normalizedName, `${escapeLikePattern(namePrefix)}%`) : undefined + ) + ) + .orderBy(desc(simLink.visitCount), asc(simLink.normalizedName)) + .limit(SUGGESTION_LIMIT) +} + /** The destination a typed name leads to, matched case- and punctuation-insensitively. */ export async function findSimLinkDestination( organizationId: string, diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts index ddd8b5c44f3..9eb8a986c48 100644 --- a/apps/sim/lib/sim-links/validation.ts +++ b/apps/sim/lib/sim-links/validation.ts @@ -19,17 +19,40 @@ export function normalizeSimLinkName(name: string): string { return name.toLowerCase().replace(NAME_PUNCTUATION, '') } +/** Text as typed after `sim/`, whether or not the prefix itself was typed too. */ +export function stripSimLinkPrefix(input: string): string { + return input.replace(/^sim\//i, '') +} + /** * Shapes typed or pasted text into a name as it is entered: a leading `sim/` is dropped, spaces * and slashes become hyphens, and any other character a name can't hold is removed. */ export function toSimLinkName(input: string): string { - return input - .replace(/^sim\//i, '') + return stripSimLinkPrefix(input) .replace(/[\s/]+/g, '-') .replace(NAME_DISALLOWED, '') } +/** Sentence punctuation and closing quotes or brackets that follow a link pasted from prose. */ +const PASTED_TRAILING_PUNCTUATION = /[.,;:!?'"’”)\]}>]+$/ + +/** + * `sim//` split into the name and its values. A name typed alone loses the + * punctuation it picked up from the sentence it was copied out of, so `sim/onboarding).` opens + * `sim/onboarding`: none of these characters can end a distinct name, since `.` and `:` are + * ignored when matching and the rest are not allowed. Values are kept verbatim, and a name that + * is only punctuation stays as typed, so it still fails validation. + */ +export function parseTypedSimLink(segments: readonly string[]): { + name: string + values: string[] +} { + const [name = '', ...values] = segments + if (values.length) return { name, values } + return { name: name.replace(PASTED_TRAILING_PUNCTUATION, '') || name, values } +} + export function isVariableSimLinkUrl(url: string): boolean { return url.includes(SIM_LINK_VARIABLE) } diff --git a/packages/browser-extension-protocol/src/index.ts b/packages/browser-extension-protocol/src/index.ts index a728e4285ae..305f47ad633 100644 --- a/packages/browser-extension-protocol/src/index.ts +++ b/packages/browser-extension-protocol/src/index.ts @@ -74,3 +74,13 @@ export const extensionToAppMessageSchema = z.discriminatedUnion('type', [ }), ]) export type ExtensionToAppMessage = z.infer + +/** Where the extension asks for the address-bar suggestions behind its `sim` keyword. */ +export const SIM_LINK_SUGGESTIONS_PATH = '/api/sim-links/suggestions' + +/** Listed Sim links whose name starts with what was typed, most visited first. */ +export const simLinkSuggestionsSchema = z.strictObject({ + links: z.array( + z.strictObject({ name: z.string(), description: z.string().nullable(), url: z.string() }) + ), +}) From 5ef061a0cbb08ff849460786761a017440ea54b5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 Oct 2026 01:18:20 -0700 Subject: [PATCH 2/2] fix(extension): keep typed values when a suggestion is chosen, bound the suggestion cache --- apps/extension/package.json | 1 + apps/extension/src/omnibox.ts | 78 +++++++++++++++++++++-------------- bun.lock | 1 + 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 796a3a2d843..9f4a578f7f8 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -18,6 +18,7 @@ "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/utils": "workspace:*", + "lru-cache": "11.5.3", "react": "19.2.4", "react-dom": "19.2.4", "zod": "4.3.6" diff --git a/apps/extension/src/omnibox.ts b/apps/extension/src/omnibox.ts index 6dd8e452cd5..52c1095b0c4 100644 --- a/apps/extension/src/omnibox.ts +++ b/apps/extension/src/omnibox.ts @@ -4,6 +4,7 @@ import { } from '@sim/browser-extension-protocol' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' +import { LRUCache } from 'lru-cache' import { SIM_ORIGIN } from '@/config' const logger = createLogger('BrowserExtensionOmnibox') @@ -11,14 +12,23 @@ const logger = createLogger('BrowserExtensionOmnibox') /** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */ const SUGGEST_DELAY_MS = 120 -/** What was typed after the `sim` keyword, whether or not `sim/` was typed too. */ -function typedSimLink(text: string): string { - return text.trim().replace(/^sim\//i, '') +/** A Sim link as suggested: its name, and what it shows beside the name. */ +interface SuggestedLink { + name: string + detail: string } -/** The name part, which alone decides the suggestions: `jira/ABC-1?x` suggests from `jira`. */ -function typedName(text: string): string { - return typedSimLink(text).split(/[/?]/)[0].toLowerCase() +/** + * What was typed after the `sim` keyword, split where the name ends: `sim/jira/ABC-1?tab=all` + * (the `sim/` is optional) has the name `jira` and the rest `/ABC-1?tab=all`. Only the space + * before the name and the prefix are dropped; values and the query stay exactly as typed. + */ +function readTyped(text: string): { name: string; rest: string } { + const typed = text.trimStart().replace(/^sim\//i, '') + const nameEnd = typed.search(/[/?]/) + return nameEnd === -1 + ? { name: typed, rest: '' } + : { name: typed.slice(0, nameEnd), rest: typed.slice(nameEnd) } } /** Suggestion text is XML, where these five characters are markup. */ @@ -27,23 +37,19 @@ function escapeXml(text: string): string { } /** - * The resolver address for what was typed after the `sim` keyword, written the way it is typed - * in the address bar: `jira/ABC-1?tab=all` (with or without a leading `sim/`) opens - * `sim/jira/ABC-1?tab=all`. Each `/` separates a value, and a value may contain spaces. + * The resolver address for what was typed, as the address bar would send `sim/`: each + * `/` starts a value, kept in position even when empty, and a trailing `/` adds none. */ function simLinkUrl(text: string): string { - const typed = typedSimLink(text) - const queryStart = typed.indexOf('?') - const path = queryStart === -1 ? typed : typed.slice(0, queryStart) - const query = queryStart === -1 ? '' : new URLSearchParams(typed.slice(queryStart + 1)).toString() - const segments = path.split('/').filter(Boolean).map(encodeURIComponent) + const { name, rest } = readTyped(text) + const queryStart = rest.indexOf('?') + const path = `${name}${queryStart === -1 ? rest : rest.slice(0, queryStart)}`.replace(/\/$/, '') + const query = queryStart === -1 ? '' : new URLSearchParams(rest.slice(queryStart + 1)).toString() + const segments = path ? path.split('/').map(encodeURIComponent) : [] return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}` } -async function fetchSuggestions( - name: string, - signal: AbortSignal -): Promise { +async function fetchSuggestedLinks(name: string, signal: AbortSignal): Promise { const response = await fetch( `${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`, { credentials: 'include', signal } @@ -51,9 +57,17 @@ async function fetchSuggestions( // Signed out, or Sim links are unavailable: the typed link still opens through the resolver. if (!response.ok) return [] const { links } = simLinkSuggestionsSchema.parse(await response.json()) + return links.map((link) => ({ name: link.name, detail: link.description ?? link.url })) +} + +/** Each link as a suggestion that keeps whatever was typed after the name. */ +function toSuggestions( + links: readonly SuggestedLink[], + rest: string +): chrome.omnibox.SuggestResult[] { return links.map((link) => ({ - content: link.name, - description: `sim/${escapeXml(link.name)} ${escapeXml(link.description ?? link.url)}`, + content: `${link.name}${rest}`, + description: `sim/${escapeXml(`${link.name}${rest}`)} ${escapeXml(link.detail)}`, })) } @@ -63,20 +77,24 @@ async function fetchSuggestions( */ export function registerSimLinkOmnibox() { let pending: AbortController | null = null - /** Suggestions by typed name for the current keyword session, so retyping and editing values reuse them. */ - const suggestionsByName = new Map() + /** + * Links by typed name, emptied as each keyword session starts, so editing values or retyping + * a name in one session asks Sim once per name. + */ + const linksByName = new LRUCache({ max: 100 }) - chrome.omnibox.onInputStarted.addListener(() => suggestionsByName.clear()) + chrome.omnibox.onInputStarted.addListener(() => linksByName.clear()) chrome.omnibox.onInputChanged.addListener((text, suggest) => { pending?.abort() + const { name, rest } = readTyped(text) chrome.omnibox.setDefaultSuggestion({ - description: `Open sim/${escapeXml(typedSimLink(text))}`, + description: `Open sim/${escapeXml(`${name}${rest}`)}`, }) - const name = typedName(text) - const known = suggestionsByName.get(name) + const key = name.toLowerCase() + const known = linksByName.get(key) if (known) { - suggest(known) + suggest(toSuggestions(known, rest)) return } const controller = new AbortController() @@ -84,9 +102,9 @@ export function registerSimLinkOmnibox() { void (async () => { await sleep(SUGGEST_DELAY_MS) if (controller.signal.aborted) return - const suggestions = await fetchSuggestions(name, controller.signal) - suggestionsByName.set(name, suggestions) - if (!controller.signal.aborted) suggest(suggestions) + const links = await fetchSuggestedLinks(key, controller.signal) + linksByName.set(key, links) + if (!controller.signal.aborted) suggest(toSuggestions(links, rest)) })().catch(() => { if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions') }) diff --git a/bun.lock b/bun.lock index 2650e445146..0525115a6fd 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,7 @@ "@sim/emcn": "workspace:*", "@sim/logger": "workspace:*", "@sim/utils": "workspace:*", + "lru-cache": "11.5.3", "react": "19.2.4", "react-dom": "19.2.4", "zod": "4.3.6",