Skip to content

Commit 2f5a99d

Browse files
committed
improvement(sim-links): pass queries through, trim pasted punctuation, and suggest links from the address bar
1 parent 3dfcaa0 commit 2f5a99d

17 files changed

Lines changed: 383 additions & 40 deletions

File tree

‎apps/docs/content/docs/search/sim-links.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook`
1515

1616
## Open links from the address bar
1717

18-
Add Sim as a Chrome site search shortcut to type `sim handbook` instead:
18+
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.
19+
20+
Without the extension, add Sim as a Chrome site search shortcut to type `sim handbook` instead:
1921

2022
1. Open Chrome **Settings → Search engine → Manage search engines and site search**.
2123
2. Next to **Site search**, select **Add**.
@@ -33,7 +35,7 @@ To share a link, right-click it and select **Copy link**.
3335

3436
## Add values to a link
3537

36-
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`.
38+
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`.
3739

3840
To place a value elsewhere, put `{*}` in the destination URL:
3941

‎apps/extension/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ SIM_EXTENSION_DEV=1 SIM_EXTENSION_ORIGIN=http://localhost:3000 bun run --cwd app
2727

2828
- Required capabilities: `activeTab`, `scripting`, `sidePanel`, `storage`, `contextMenus`, `declarativeNetRequestWithHostAccess`, and access to the configured Sim origin and to `http(s)://sim/*`.
2929
- Typing `sim/<name>` 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 origin>/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.
30+
- 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 `<Sim origin>/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.
3031
- 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.
3132
- 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.
3233
- Extraction stops at 50,000 text characters, 150,000 serialized UTF-8 bytes, 15,000 visited nodes, or 150ms. The composer exposes truncation.

‎apps/extension/scripts/build.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ await writeFile(
125125
},
126126
optional_host_permissions: ['https://*/*', 'http://*/*'],
127127
background: { service_worker: 'background.js' },
128+
omnibox: { keyword: 'sim' },
128129
side_panel: { default_path: 'sidepanel.html' },
129130
action: { default_title: 'Open Sim' },
130131
icons: { 16: 'icon-16.png', 32: 'icon-32.png', 128: 'icon-128.png' },
@@ -135,7 +136,7 @@ await writeFile(
135136
},
136137
},
137138
content_security_policy: {
138-
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'`,
139+
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'`,
139140
},
140141
incognito: 'not_allowed',
141142
...(key ? { key } : {}),

‎apps/extension/src/background.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { generateId } from '@sim/utils/id'
33
import { CAPTURE_INTENT_KEY, captureIntentKey } from '@/config'
44
import { captureIntentSchema } from '@/intent'
5+
import { registerSimLinkOmnibox } from '@/omnibox'
56
import { digestUrl } from '@/utils'
67

78
const logger = createLogger('BrowserExtension')
@@ -50,6 +51,7 @@ function open(tab: chrome.tabs.Tab, kind: 'page' | 'selection') {
5051
.catch(() => logger.warn('Could not attach this page; the side panel remains available'))
5152
}
5253

54+
registerSimLinkOmnibox()
5355
chrome.runtime.onInstalled.addListener(() => {
5456
void configure().catch(() => logger.warn('Could not initialize extension controls'))
5557
})

‎apps/extension/src/omnibox.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {
2+
SIM_LINK_SUGGESTIONS_PATH,
3+
simLinkSuggestionsSchema,
4+
} from '@sim/browser-extension-protocol'
5+
import { createLogger } from '@sim/logger'
6+
import { sleep } from '@sim/utils/helpers'
7+
import { SIM_ORIGIN } from '@/config'
8+
9+
const logger = createLogger('BrowserExtensionOmnibox')
10+
11+
/** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */
12+
const SUGGEST_DELAY_MS = 120
13+
14+
/** What was typed after the `sim` keyword, whether or not `sim/` was typed too. */
15+
function typedSimLink(text: string): string {
16+
return text.trim().replace(/^sim\//i, '')
17+
}
18+
19+
/** The name part, which alone decides the suggestions: `jira/ABC-1?x` suggests from `jira`. */
20+
function typedName(text: string): string {
21+
return typedSimLink(text).split(/[/?]/)[0].toLowerCase()
22+
}
23+
24+
/** Suggestion text is XML, where these five characters are markup. */
25+
function escapeXml(text: string): string {
26+
return text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
27+
}
28+
29+
/**
30+
* The resolver address for what was typed after the `sim` keyword, written the way it is typed
31+
* in the address bar: `jira/ABC-1?tab=all` (with or without a leading `sim/`) opens
32+
* `sim/jira/ABC-1?tab=all`. Each `/` separates a value, and a value may contain spaces.
33+
*/
34+
function simLinkUrl(text: string): string {
35+
const typed = typedSimLink(text)
36+
const queryStart = typed.indexOf('?')
37+
const path = queryStart === -1 ? typed : typed.slice(0, queryStart)
38+
const query = queryStart === -1 ? '' : new URLSearchParams(typed.slice(queryStart + 1)).toString()
39+
const segments = path.split('/').filter(Boolean).map(encodeURIComponent)
40+
return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}`
41+
}
42+
43+
async function fetchSuggestions(
44+
name: string,
45+
signal: AbortSignal
46+
): Promise<chrome.omnibox.SuggestResult[]> {
47+
const response = await fetch(
48+
`${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`,
49+
{ credentials: 'include', signal }
50+
)
51+
// Signed out, or Sim links are unavailable: the typed link still opens through the resolver.
52+
if (!response.ok) return []
53+
const { links } = simLinkSuggestionsSchema.parse(await response.json())
54+
return links.map((link) => ({
55+
content: link.name,
56+
description: `<match>sim/${escapeXml(link.name)}</match> <dim>${escapeXml(link.description ?? link.url)}</dim>`,
57+
}))
58+
}
59+
60+
/**
61+
* The `sim` address-bar keyword: `sim`, then Space, lists matching Sim links as you type, and
62+
* Enter opens what was typed through the resolver, which sends a missing link to its create form.
63+
*/
64+
export function registerSimLinkOmnibox() {
65+
let pending: AbortController | null = null
66+
/** Suggestions by typed name for the current keyword session, so retyping and editing values reuse them. */
67+
const suggestionsByName = new Map<string, chrome.omnibox.SuggestResult[]>()
68+
69+
chrome.omnibox.onInputStarted.addListener(() => suggestionsByName.clear())
70+
71+
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
72+
pending?.abort()
73+
chrome.omnibox.setDefaultSuggestion({
74+
description: `Open <match>sim/${escapeXml(typedSimLink(text))}</match>`,
75+
})
76+
const name = typedName(text)
77+
const known = suggestionsByName.get(name)
78+
if (known) {
79+
suggest(known)
80+
return
81+
}
82+
const controller = new AbortController()
83+
pending = controller
84+
void (async () => {
85+
await sleep(SUGGEST_DELAY_MS)
86+
if (controller.signal.aborted) return
87+
const suggestions = await fetchSuggestions(name, controller.signal)
88+
suggestionsByName.set(name, suggestions)
89+
if (!controller.signal.aborted) suggest(suggestions)
90+
})().catch(() => {
91+
if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions')
92+
})
93+
})
94+
95+
chrome.omnibox.onInputCancelled.addListener(() => pending?.abort())
96+
97+
chrome.omnibox.onInputEntered.addListener((text, disposition) => {
98+
pending?.abort()
99+
const url = simLinkUrl(text)
100+
const opened =
101+
disposition === 'currentTab'
102+
? chrome.tabs.update({ url })
103+
: chrome.tabs.create({ url, active: disposition === 'newForegroundTab' })
104+
void opened.catch(() => logger.warn('Could not open the Sim link'))
105+
})
106+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { suggestSimLinksContract } from '@/lib/api/contracts/sim-links'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { suggestSimLinks } from '@/lib/sim-links/application/use-cases'
9+
10+
export const GET = defineInternalJsonRoute({
11+
contract: suggestSimLinksContract,
12+
operation: suggestSimLinks.operation,
13+
useCase: suggestSimLinks,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.user({
16+
bucketName: 'sim-link-suggestions',
17+
// The address bar asks again as each character is typed.
18+
config: { maxTokens: 120, refillRate: 120, refillIntervalMs: 60_000 },
19+
}),
20+
errorPolicy: internalOrchestrationErrorPolicy,
21+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
22+
mapInput: ({ query }) => ({ query: query.q }),
23+
present: (links) => ({ links }),
24+
})

‎apps/sim/app/sim/[[...path]]/page.tsx‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { ChipLink, StatusPageContent } from '@sim/emcn'
22
import type { Metadata } from 'next'
33
import { notFound, redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
5-
import { getActiveOrganizationId } from '@/lib/auth/session-response'
65
import { organizationRoutes, simLinkPath } from '@/lib/navigation/paths'
7-
import { resolveOrganizationLanding } from '@/lib/organizations/surface'
6+
import { resolveSimLinkOrganization } from '@/lib/sim-links/application/organization'
87
import { resolveSimLink, type SimLinkResolution } from '@/lib/sim-links/application/use-cases'
9-
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
10-
import { simLinkNameSchema } from '@/lib/sim-links/validation'
8+
import { parseTypedSimLink, simLinkNameSchema } from '@/lib/sim-links/validation'
119
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1210
import { LogoShell } from '@/app/(landing)/components/logo-shell'
1311
import { serializeSimLinkView } from '@/app/o/[organizationId]/links/search-params'
@@ -19,6 +17,7 @@ export const metadata: Metadata = {
1917

2018
interface SimLinkPageProps {
2119
params: Promise<{ path?: string[] }>
20+
searchParams: Promise<Record<string, string | string[] | undefined>>
2221
}
2322

2423
/**
@@ -32,6 +31,15 @@ function decodeSimLinkPath(path: string[]): string[] {
3231
return segments.length === 1 ? segments[0].split('/').filter(Boolean) : segments
3332
}
3433

34+
/** The typed `?…` query, rebuilt from Next's parsed params, which group repeated keys. */
35+
function toQueryString(searchParams: Record<string, string | string[] | undefined>): string {
36+
const query = new URLSearchParams()
37+
for (const [key, value] of Object.entries(searchParams)) {
38+
for (const item of [value ?? []].flat()) query.append(key, item)
39+
}
40+
return query.toString()
41+
}
42+
3543
/**
3644
* Why a link did not redirect. Only a valid link name is echoed back, so an arbitrary path
3745
* can't put its own text on this page.
@@ -47,32 +55,34 @@ function describeUnresolved(result: SimLinkResolution, validName: boolean, name:
4755
}
4856

4957
/**
50-
* Where `sim/<name>/<values…>` lands: the viewer's organization link's destination,
58+
* Where `sim/<name>/<values…>?<query>` lands: the viewer's organization link's destination,
5159
* or a page that offers to create the link when nobody has yet.
5260
*/
53-
export default async function SimLinkPage({ params }: SimLinkPageProps) {
54-
const { path = [] } = await params
61+
export default async function SimLinkPage({ params, searchParams }: SimLinkPageProps) {
62+
const [{ path = [] }, query] = await Promise.all([params, searchParams.then(toQueryString)])
5563
const segments = decodeSimLinkPath(path)
5664
const session = await getSession()
5765
if (!session?.user)
5866
redirect(
5967
buildAuthCrossLink('/login', {
60-
callbackUrl: simLinkPath(...segments),
68+
callbackUrl: `${simLinkPath(...segments)}${query ? `?${query}` : ''}`,
6169
isInviteFlow: false,
6270
})
6371
)
64-
const organizationId = await resolveOrganizationLanding(
65-
session.user.id,
66-
getActiveOrganizationId(session)
67-
)
68-
if (!organizationId || !(await isSimLinksEnabled(organizationId))) notFound()
72+
const principal = {
73+
kind: 'session',
74+
userId: session.user.id,
75+
sessionId: session.session.id,
76+
} as const
77+
const organizationId = await resolveSimLinkOrganization(principal)
78+
if (!organizationId) notFound()
6979
const library = organizationRoutes(organizationId).simLinks
70-
const [name, ...values] = segments
80+
const { name, values } = parseTypedSimLink(segments)
7181
if (!name) redirect(library)
7282

7383
const result = await resolveSimLink.execute({
74-
principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
75-
input: { organizationId, name, values },
84+
principal,
85+
input: { organizationId, name, values, query },
7686
})
7787
if (result.kind === 'redirect') redirect(result.url)
7888

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
SIM_LINK_SUGGESTIONS_PATH,
3+
simLinkSuggestionsSchema,
4+
} from '@sim/browser-extension-protocol'
15
import { z } from 'zod'
26
import { defineRouteContract } from '@/lib/api/contracts'
37
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
@@ -74,3 +78,11 @@ export const deleteSimLinkContract = defineRouteContract({
7478
params: linkParams,
7579
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
7680
})
81+
82+
/** The browser extension's address-bar suggestions, from the organization `sim/` opens in. */
83+
export const suggestSimLinksContract = defineRouteContract({
84+
method: 'GET',
85+
path: SIM_LINK_SUGGESTIONS_PATH,
86+
query: z.object({ q: z.string().max(200).default('') }),
87+
response: { mode: 'json', schema: simLinkSuggestionsSchema },
88+
})

‎apps/sim/lib/organizations/surface.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { cache } from 'react'
2+
import type { SessionPrincipal } from '@sim/auth/principal'
23
import { db } from '@sim/db'
3-
import { member, organization } from '@sim/db/schema'
4-
import { asc, count, eq } from 'drizzle-orm'
4+
import { member, organization, session } from '@sim/db/schema'
5+
import { and, asc, count, eq } from 'drizzle-orm'
56
import {
67
getOrganizationSettingsFeatures,
78
type OrganizationSettingsFeatures,
@@ -172,3 +173,19 @@ export async function resolveOrganizationLanding(
172173
memberships.some((row) => row.organizationId === activeOrganizationId)
173174
return isActiveMember ? activeOrganizationId : memberships[0].organizationId
174175
}
176+
177+
/**
178+
* {@link resolveOrganizationLanding} for a session known only by its principal, reading the
179+
* active organization stored on the session, so an API call lands where a page load would.
180+
*/
181+
export async function resolveSessionOrganizationLanding({
182+
userId,
183+
sessionId,
184+
}: SessionPrincipal): Promise<string | null> {
185+
const [row] = await db
186+
.select({ activeOrganizationId: session.activeOrganizationId })
187+
.from(session)
188+
.where(and(eq(session.id, sessionId), eq(session.userId, userId)))
189+
.limit(1)
190+
return resolveOrganizationLanding(userId, row?.activeOrganizationId ?? null)
191+
}

‎apps/sim/lib/sim-links/application/operations.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { defineOperation } from '@/lib/core/application'
12
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
23

34
const policy = { minimumRole: 'member', principalKinds: ['session'] } as const
@@ -34,4 +35,11 @@ export const simLinkOperations = {
3435
...policy,
3536
capability: 'none',
3637
}),
38+
/** Not organization-scoped: it serves whichever organization `sim/` opens in for the session. */
39+
// permission-group-exempt: Sim links are organization-wide shortcuts, not a governed capability.
40+
suggest: defineOperation({
41+
id: 'sim_links.suggest',
42+
principalKinds: ['session'],
43+
capability: 'none',
44+
}),
3745
} as const

0 commit comments

Comments
 (0)