diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7f2c10aa6c..2ee59752f4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -51,6 +51,7 @@
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
+ "mdast-util-find-and-replace": "^3.0.2",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -71,6 +72,7 @@
"svelte-exmarkdown": "^5.0.0",
"svelte-infinite-loading": "^1.4.0",
"tailwind-merge": "^1.13.2",
+ "unist-util-visit": "^5.0.0",
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
"vscode-languageclient": "~9.0.1",
"vscode-uri": "~3.1.0",
diff --git a/frontend/package.json b/frontend/package.json
index c379388fd6..c7f0c548e0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -124,6 +124,8 @@
"jszip": "^3.10.1",
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
+ "mdast-util-find-and-replace": "^3.0.2",
+ "unist-util-visit": "^5.0.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte
index 7d39f97274..186306fa23 100644
--- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte
+++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte
@@ -4,12 +4,56 @@
import type { DisplayMessage } from './shared'
import CodeDisplay from './script/CodeDisplay.svelte'
import LinkRenderer from './LinkRenderer.svelte'
+ import { workspaceStore } from '$lib/stores'
+ import {
+ extractCandidatePaths,
+ remarkWindmillPaths,
+ workspaceItemRegistry
+ } from './workspaceItems.svelte'
interface Props {
message: DisplayMessage
}
let { message }: Props = $props()
+
+ const candidatePaths = $derived(extractCandidatePaths(message.content))
+ const rendererPlugin = {
+ renderer: {
+ pre: CodeDisplay,
+ a: LinkRenderer
+ }
+ }
+
+ // Only populate the registry for messages that contain path-shaped tokens. The
+ // registry still dedups concurrent calls across messages and workspaces.
+ $effect(() => {
+ const ws = $workspaceStore
+ if (ws && candidatePaths.length > 0) workspaceItemRegistry.ensureLoaded(ws)
+ })
+
+ const plugins = $derived.by(() => {
+ const ws = $workspaceStore ?? ''
+ if (!ws || candidatePaths.length === 0) {
+ return [gfmPlugin(), rendererPlugin]
+ }
+
+ if (!workspaceItemRegistry.isLoaded(ws)) {
+ return [gfmPlugin(), rendererPlugin]
+ }
+
+ return [
+ gfmPlugin(),
+ {
+ remarkPlugin: remarkWindmillPaths({
+ resolve: (path) => workspaceItemRegistry.resolve(ws, path),
+ workspace: ws || undefined
+ }),
+ renderer: {}
+ },
+ rendererPlugin
+ ]
+ })
-
+
diff --git a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte
index 7d3b8a950c..b73c8dc6bc 100644
--- a/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte
+++ b/frontend/src/lib/components/copilot/chat/CreatedResourceActionDrawers.svelte
@@ -81,6 +81,10 @@
azure: {
label: 'Azure Event Grid trigger',
load: () => import('$lib/components/triggers/azure/AzureTriggerEditorInner.svelte')
+ },
+ email: {
+ label: 'Email trigger',
+ load: () => import('$lib/components/triggers/email/EmailTriggerEditorInner.svelte')
}
}
@@ -139,13 +143,25 @@
throw new Error('Missing trigger kind')
}
+ if (activeDrawer?.key === key && activeDrawer.path === action.path && drawer?.isOpen()) {
+ activeDrawer = undefined
+ editor = undefined
+ drawer.closeDrawer()
+ return
+ }
+
const config = drawerConfigs[key]
const promise = activeDrawer?.key === key ? activeDrawer.promise : config.load()
if (activeDrawer?.key !== key) {
editor = undefined
}
- const request: ActiveDrawerState = { id: nextActiveDrawerId++, key, path: action.path, promise }
+ const request: ActiveDrawerState = {
+ id: nextActiveDrawerId++,
+ key,
+ path: action.path,
+ promise
+ }
activeDrawer = request
drawer?.openDrawer()
diff --git a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte
index 87d131e03e..5d79251fd2 100644
--- a/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte
+++ b/frontend/src/lib/components/copilot/chat/LinkRenderer.svelte
@@ -1,15 +1,81 @@
{#if href}
-
- {@render children?.()}
-
+ {#if wmKind}
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
+ {#if drawerAction}
+
+ {/if}
+
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
{/if}
diff --git a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte
index 00eff03b89..0a2fd1ab79 100644
--- a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte
+++ b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte
@@ -4,6 +4,7 @@
Calendar,
Database,
KeyRound,
+ Mail,
Package,
Route,
SquarePen,
@@ -44,7 +45,8 @@
mqtt: { title: 'MQTT trigger', icon: MqttIcon },
sqs: { title: 'SQS trigger', icon: AwsIcon },
gcp: { title: 'GCP Pub/Sub trigger', icon: GoogleCloudIcon },
- azure: { title: 'Azure Event Grid trigger', icon: AzureIcon }
+ azure: { title: 'Azure Event Grid trigger', icon: AzureIcon },
+ email: { title: 'Email trigger', icon: Mail }
}
function getActionCardConfig(action: ToolDisplayAction): ActionCardConfig {
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index 4deeaf231a..1836267cd3 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -466,6 +466,7 @@ export type CreatedResourceTriggerKind =
| 'sqs'
| 'gcp'
| 'azure'
+ | 'email'
export type CreatedResourceAction = {
id: string
diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts
new file mode 100644
index 0000000000..bc46d644df
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/workspaceItems.svelte.ts
@@ -0,0 +1,306 @@
+import {
+ AppService,
+ AzureTriggerService,
+ EmailTriggerService,
+ FlowService,
+ GcpTriggerService,
+ HttpTriggerService,
+ KafkaTriggerService,
+ MqttTriggerService,
+ NatsTriggerService,
+ PostgresTriggerService,
+ ResourceService,
+ ScheduleService,
+ ScriptService,
+ SqsTriggerService,
+ VariableService,
+ WebsocketTriggerService
+} from '$lib/gen'
+import { itemHref as offboardingItemHref } from '$lib/components/offboarding-utils'
+import { findAndReplace } from 'mdast-util-find-and-replace'
+import { visit } from 'unist-util-visit'
+import type { Root, InlineCode, Link } from 'mdast'
+import type { ToolDisplayAction } from './shared'
+
+export type WindmillItemKind =
+ | 'script'
+ | 'flow'
+ | 'app'
+ | 'variable'
+ | 'resource'
+ | 'schedule'
+ | 'http_trigger'
+ | 'websocket_trigger'
+ | 'kafka_trigger'
+ | 'nats_trigger'
+ | 'postgres_trigger'
+ | 'mqtt_trigger'
+ | 'sqs_trigger'
+ | 'gcp_trigger'
+ | 'azure_trigger'
+ | 'email_trigger'
+
+export interface WorkspaceItemEntry {
+ kind: WindmillItemKind
+ path: string
+ targetKind?: WorkspaceItemTargetKind
+}
+
+export type WorkspaceItemTargetKind = 'script' | 'flow'
+
+/**
+ * Matches Windmill paths of the form `u//` or `f//`.
+ *
+ * Owners may contain `[A-Za-z0-9._-]`; the trailing path can include dots and slashes
+ * (sub-paths, version segments) but must end on an alphanumeric / underscore / hyphen —
+ * this prevents the regex from gobbling sentence punctuation like the period in
+ * "look at f/foo/bar."
+ *
+ * The negative lookbehind prevents matches embedded in URLs or longer identifiers.
+ */
+export const WINDMILL_PATH_REGEX =
+ /(?
+
+const workspaceItemLoaders: Array<{
+ kind: WindmillItemKind
+ list: (workspace: string) => Promise
+}> = [
+ // First writer wins on path collisions. Keep resources before variables because
+ // Windmill creates a companion variable for each resource at the same path.
+ { kind: 'script', list: (workspace) => ScriptService.listScripts({ workspace }) },
+ { kind: 'flow', list: (workspace) => FlowService.listFlows({ workspace }) },
+ { kind: 'app', list: (workspace) => AppService.listApps({ workspace }) },
+ { kind: 'resource', list: (workspace) => ResourceService.listResource({ workspace }) },
+ { kind: 'variable', list: (workspace) => VariableService.listVariable({ workspace }) },
+ { kind: 'schedule', list: (workspace) => ScheduleService.listSchedules({ workspace }) },
+ { kind: 'http_trigger', list: (workspace) => HttpTriggerService.listHttpTriggers({ workspace }) },
+ {
+ kind: 'websocket_trigger',
+ list: (workspace) => WebsocketTriggerService.listWebsocketTriggers({ workspace })
+ },
+ {
+ kind: 'kafka_trigger',
+ list: (workspace) => KafkaTriggerService.listKafkaTriggers({ workspace })
+ },
+ { kind: 'nats_trigger', list: (workspace) => NatsTriggerService.listNatsTriggers({ workspace }) },
+ {
+ kind: 'postgres_trigger',
+ list: (workspace) => PostgresTriggerService.listPostgresTriggers({ workspace })
+ },
+ { kind: 'mqtt_trigger', list: (workspace) => MqttTriggerService.listMqttTriggers({ workspace }) },
+ { kind: 'sqs_trigger', list: (workspace) => SqsTriggerService.listSqsTriggers({ workspace }) },
+ { kind: 'gcp_trigger', list: (workspace) => GcpTriggerService.listGcpTriggers({ workspace }) },
+ {
+ kind: 'azure_trigger',
+ list: (workspace) => AzureTriggerService.listAzureTriggers({ workspace })
+ },
+ {
+ kind: 'email_trigger',
+ list: (workspace) => EmailTriggerService.listEmailTriggers({ workspace })
+ }
+]
+
+/**
+ * Reactive registry that caches workspace item paths per workspace.
+ *
+ * - Loads lazily on first call to `ensureLoaded`
+ * - Dedups concurrent in-flight loads
+ * - Exposes a reactive map (`$state`) so consumers re-render once data lands
+ */
+class WorkspaceItemRegistry {
+ #byWorkspace: Map> = $state(new Map())
+ #inflight: Map> = new Map()
+
+ private async load(workspace: string): Promise {
+ const loadedItems = await Promise.all(
+ workspaceItemLoaders.map(async ({ kind, list }) => ({
+ kind,
+ items: await list(workspace).catch(() => [])
+ }))
+ )
+
+ const map = new Map()
+ for (const { kind, items } of loadedItems) {
+ for (const it of items) {
+ if (!map.has(it.path)) {
+ map.set(it.path, {
+ kind,
+ path: it.path,
+ targetKind:
+ typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined
+ })
+ }
+ }
+ }
+
+ // Build a new outer map to trigger reactivity on consumers using $derived.
+ const next = new Map(this.#byWorkspace)
+ next.set(workspace, map)
+ this.#byWorkspace = next
+ }
+
+ /** Ensure the workspace items are loaded. Returns the in-flight promise if any. */
+ ensureLoaded(workspace: string): Promise {
+ if (!workspace) return Promise.resolve()
+ if (this.#byWorkspace.has(workspace)) return Promise.resolve()
+ let pending = this.#inflight.get(workspace)
+ if (!pending) {
+ pending = this.load(workspace).finally(() => this.#inflight.delete(workspace))
+ this.#inflight.set(workspace, pending)
+ }
+ return pending
+ }
+
+ /** Synchronously resolve a path. Returns undefined if the workspace isn't loaded yet. */
+ resolve(workspace: string, path: string): WorkspaceItemEntry | undefined {
+ if (!workspace) return undefined
+ return this.#byWorkspace.get(workspace)?.get(path)
+ }
+
+ /** Whether the registry has data for the given workspace. */
+ isLoaded(workspace: string): boolean {
+ return this.#byWorkspace.has(workspace)
+ }
+}
+
+export const workspaceItemRegistry = new WorkspaceItemRegistry()
+
+/** Extract every Windmill-looking path from raw text (no resolution against the registry). */
+export function extractCandidatePaths(text: string | undefined | null): string[] {
+ if (!text) return []
+ const seen = new Set()
+ for (const match of text.matchAll(WINDMILL_PATH_REGEX)) {
+ seen.add(match[1])
+ }
+ return [...seen]
+}
+
+export function workspaceItemAction(
+ kind: WindmillItemKind | undefined,
+ path: string | undefined,
+ targetKind?: WorkspaceItemTargetKind
+): ToolDisplayAction | undefined {
+ if (!kind || !path) return undefined
+
+ const base = {
+ id: `open_workspace_item:${kind}:${path}`,
+ type: 'open_created_resource' as const,
+ label: `Open ${path}`,
+ path
+ }
+
+ if (kind === 'resource' || kind === 'variable') {
+ return { ...base, resource: kind }
+ }
+
+ if (kind === 'schedule') {
+ return targetKind ? { ...base, resource: 'schedule', targetKind } : undefined
+ }
+
+ const triggerKind = workspaceItemTriggerKind(kind)
+ if (!triggerKind || !targetKind) return undefined
+ return { ...base, resource: 'trigger', triggerKind, targetKind }
+}
+
+/** Build the link node used to replace a resolved path token. */
+function buildPathLinkNode(
+ entry: WorkspaceItemEntry,
+ displayPath: string,
+ workspace: string | undefined
+): Link {
+ const hProperties: Record = {
+ 'data-wm-kind': entry.kind,
+ 'data-wm-path': entry.path
+ }
+ if (entry.targetKind) {
+ hProperties['data-wm-target-kind'] = entry.targetKind
+ }
+
+ return {
+ type: 'link',
+ url: itemHref(entry, workspace),
+ title: null,
+ data: {
+ hProperties
+ },
+ children: [{ type: 'text', value: displayPath }]
+ }
+}
+
+/**
+ * Remark plugin that rewrites Windmill path tokens (`u/...`, `f/...`) into link nodes,
+ * but only when the path resolves to a known workspace item.
+ *
+ * Handles two cases:
+ * 1. Bare path tokens in regular text — handled by `findAndReplace`, which only visits Text
+ * nodes (so fenced code and inline code are naturally skipped). We additionally `ignore`
+ * existing `link` nodes so we don't break autolinked URLs.
+ * 2. Inline-code spans whose entire content is a single path — handled by a second pass via
+ * `unist-util-visit`. LLMs often wrap identifiers in backticks (`` `u/admin/foo` ``);
+ * when the inline code is *just* a path we treat the backticks as styling and replace
+ * the node with a link pill. Mixed inline-code content (e.g. `` `f/foo + extra text` ``)
+ * is left untouched.
+ */
+export function remarkWindmillPaths(options: {
+ resolve: (path: string) => WorkspaceItemEntry | undefined
+ workspace?: string
+}) {
+ return () => (tree: Root) => {
+ findAndReplace(
+ tree,
+ [
+ WINDMILL_PATH_REGEX,
+ (_match: string, path: string) => {
+ const entry = options.resolve(path)
+ if (!entry) return false
+ return buildPathLinkNode(entry, path, options.workspace)
+ }
+ ],
+ { ignore: ['link', 'linkReference'] }
+ )
+
+ visit(tree, 'inlineCode', (node: InlineCode, index, parent) => {
+ if (!parent || typeof index !== 'number') return
+ const value = node.value.trim()
+ if (!WINDMILL_PATH_EXACT_REGEX.test(value)) return
+ const entry = options.resolve(value)
+ if (!entry) return
+ parent.children[index] = buildPathLinkNode(entry, value, options.workspace) as any
+ })
+ }
+}
diff --git a/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts
new file mode 100644
index 0000000000..9bb477fa83
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/workspaceItems.test.ts
@@ -0,0 +1,342 @@
+import { describe, expect, it, vi } from 'vitest'
+import { unified } from 'unified'
+import remarkParse from 'remark-parse'
+import remarkRehype from 'remark-rehype'
+import type { Root as MdastRoot, Link, Text } from 'mdast'
+
+vi.mock('$lib/gen', () => ({
+ ScriptService: { listScripts: vi.fn() },
+ FlowService: { listFlows: vi.fn() },
+ AppService: { listApps: vi.fn() },
+ VariableService: { listVariable: vi.fn() },
+ ResourceService: { listResource: vi.fn() },
+ ScheduleService: { listSchedules: vi.fn() },
+ HttpTriggerService: { listHttpTriggers: vi.fn() },
+ WebsocketTriggerService: { listWebsocketTriggers: vi.fn() },
+ KafkaTriggerService: { listKafkaTriggers: vi.fn() },
+ NatsTriggerService: { listNatsTriggers: vi.fn() },
+ PostgresTriggerService: { listPostgresTriggers: vi.fn() },
+ MqttTriggerService: { listMqttTriggers: vi.fn() },
+ SqsTriggerService: { listSqsTriggers: vi.fn() },
+ GcpTriggerService: { listGcpTriggers: vi.fn() },
+ AzureTriggerService: { listAzureTriggers: vi.fn() },
+ EmailTriggerService: { listEmailTriggers: vi.fn() }
+}))
+
+import {
+ extractCandidatePaths,
+ itemHref,
+ remarkWindmillPaths,
+ WINDMILL_PATH_REGEX,
+ workspaceItemAction,
+ type WorkspaceItemEntry
+} from './workspaceItems.svelte'
+
+describe('WINDMILL_PATH_REGEX', () => {
+ it('matches simple folder and user paths', () => {
+ expect(extractCandidatePaths('Use f/marketing/send_email today')).toEqual([
+ 'f/marketing/send_email'
+ ])
+ expect(extractCandidatePaths('Check u/admin/cleanup')).toEqual(['u/admin/cleanup'])
+ })
+
+ it('matches paths with sub-folders and dotted segments', () => {
+ expect(extractCandidatePaths('Pipeline f/etl/jobs/ingest_users.v2 runs nightly')).toEqual([
+ 'f/etl/jobs/ingest_users.v2'
+ ])
+ })
+
+ it('matches usernames containing dots (e.g. firstname.lastname)', () => {
+ expect(extractCandidatePaths('Owned by u/jane.doe/report')).toEqual(['u/jane.doe/report'])
+ })
+
+ it('strips trailing sentence punctuation', () => {
+ expect(extractCandidatePaths('Look at f/foo/bar.')).toEqual(['f/foo/bar'])
+ expect(extractCandidatePaths('Try f/foo/bar, please')).toEqual(['f/foo/bar'])
+ expect(extractCandidatePaths('Is f/foo/bar?')).toEqual(['f/foo/bar'])
+ })
+
+ it('skips matches inside URLs', () => {
+ expect(extractCandidatePaths('See https://example.com/u/me/script for context')).toEqual([])
+ })
+
+ it('does not match incomplete paths', () => {
+ expect(extractCandidatePaths('I tried f/folder/ but nothing')).toEqual([])
+ expect(extractCandidatePaths('Just u/me')).toEqual([])
+ })
+
+ it('returns multiple unique paths from one string', () => {
+ const paths = extractCandidatePaths('Run f/a/one then f/b/two and again f/a/one')
+ expect(paths.sort()).toEqual(['f/a/one', 'f/b/two'])
+ })
+
+ it('exposes a global regex', () => {
+ expect(WINDMILL_PATH_REGEX.global).toBe(true)
+ })
+})
+
+describe('itemHref', () => {
+ it('routes script/flow/app to /get/{path}', () => {
+ expect(itemHref({ kind: 'script', path: 'f/a/b' })).toBe('/scripts/get/f/a/b')
+ expect(itemHref({ kind: 'flow', path: 'f/a/b' }, 'admins')).toBe(
+ '/flows/get/f/a/b?workspace=admins'
+ )
+ expect(itemHref({ kind: 'app', path: 'u/me/dash' }, 'ws1')).toBe(
+ '/apps/get/u/me/dash?workspace=ws1'
+ )
+ })
+
+ it('routes variable / resource / schedule to list page with hash fragment', () => {
+ expect(itemHref({ kind: 'variable', path: 'u/me/secret' })).toBe('/variables#u/me/secret')
+ expect(itemHref({ kind: 'resource', path: 'u/me/db' }, 'ws1')).toBe(
+ '/resources?workspace=ws1#/resource/u/me/db'
+ )
+ expect(itemHref({ kind: 'schedule', path: 'f/etl/daily' })).toBe('/schedules#f/etl/daily')
+ })
+
+ it('routes each trigger kind with hash fragment', () => {
+ const cases: Array<[WorkspaceItemEntry['kind'], string]> = [
+ ['http_trigger', '/routes#f/a/b'],
+ ['websocket_trigger', '/websocket_triggers#f/a/b'],
+ ['kafka_trigger', '/kafka_triggers#f/a/b'],
+ ['nats_trigger', '/nats_triggers#f/a/b'],
+ ['postgres_trigger', '/postgres_triggers#f/a/b'],
+ ['mqtt_trigger', '/mqtt_triggers#f/a/b'],
+ ['sqs_trigger', '/sqs_triggers#f/a/b'],
+ ['gcp_trigger', '/gcp_triggers#f/a/b'],
+ ['azure_trigger', '/azure_triggers#f/a/b'],
+ ['email_trigger', '/email_triggers#f/a/b']
+ ]
+ for (const [kind, expected] of cases) {
+ expect(itemHref({ kind, path: 'f/a/b' })).toBe(expected)
+ }
+ })
+
+ it('puts workspace query param before the hash so the router applies it', () => {
+ expect(itemHref({ kind: 'variable', path: 'u/me/secret' }, 'ws1')).toBe(
+ '/variables?workspace=ws1#u/me/secret'
+ )
+ expect(itemHref({ kind: 'http_trigger', path: 'f/a/b' }, 'ws1')).toBe(
+ '/routes?workspace=ws1#f/a/b'
+ )
+ })
+})
+
+describe('workspaceItemAction', () => {
+ it('creates drawer actions for variables and resources', () => {
+ expect(workspaceItemAction('variable', 'u/me/secret')).toMatchObject({
+ type: 'open_created_resource',
+ resource: 'variable',
+ path: 'u/me/secret'
+ })
+ expect(workspaceItemAction('resource', 'u/me/db')).toMatchObject({
+ type: 'open_created_resource',
+ resource: 'resource',
+ path: 'u/me/db'
+ })
+ })
+
+ it('creates drawer actions for schedules and triggers when target kind is known', () => {
+ expect(workspaceItemAction('schedule', 'f/etl/daily', 'flow')).toMatchObject({
+ resource: 'schedule',
+ targetKind: 'flow'
+ })
+ expect(workspaceItemAction('http_trigger', 'f/api/route', 'script')).toMatchObject({
+ resource: 'trigger',
+ triggerKind: 'http',
+ targetKind: 'script'
+ })
+ expect(workspaceItemAction('email_trigger', 'f/mail/inbox', 'script')).toMatchObject({
+ resource: 'trigger',
+ triggerKind: 'email',
+ targetKind: 'script'
+ })
+ })
+
+ it('skips non-drawerable items and trigger items without target kind', () => {
+ expect(workspaceItemAction('script', 'f/a/b')).toBeUndefined()
+ expect(workspaceItemAction('flow', 'f/a/b')).toBeUndefined()
+ expect(workspaceItemAction('app', 'f/a/b')).toBeUndefined()
+ expect(workspaceItemAction('schedule', 'f/a/b')).toBeUndefined()
+ expect(workspaceItemAction('http_trigger', 'f/a/b')).toBeUndefined()
+ })
+})
+
+const SAMPLE_ENTRIES: Record = {
+ 'f/marketing/send_email': {
+ kind: 'script',
+ path: 'f/marketing/send_email'
+ },
+ 'u/admin/cleanup_old_jobs': {
+ kind: 'flow',
+ path: 'u/admin/cleanup_old_jobs'
+ },
+ 'f/ops/dashboard': { kind: 'app', path: 'f/ops/dashboard' },
+ 'f/etl/daily': {
+ kind: 'schedule',
+ path: 'f/etl/daily',
+ targetKind: 'flow'
+ }
+}
+
+function buildProcessor(workspace?: string) {
+ return unified()
+ .use(remarkParse)
+ .use(remarkWindmillPaths({ resolve: (p) => SAMPLE_ENTRIES[p], workspace }))
+}
+
+function findLinks(tree: MdastRoot): Link[] {
+ const out: Link[] = []
+ const walk = (node: any) => {
+ if (!node) return
+ if (node.type === 'link') out.push(node as Link)
+ if (Array.isArray(node.children)) node.children.forEach(walk)
+ }
+ walk(tree)
+ return out
+}
+
+function findText(tree: MdastRoot): Text[] {
+ const out: Text[] = []
+ const walk = (node: any) => {
+ if (!node) return
+ if (node.type === 'text') out.push(node as Text)
+ if (Array.isArray(node.children)) node.children.forEach(walk)
+ }
+ walk(tree)
+ return out
+}
+
+describe('remarkWindmillPaths (mdast)', () => {
+ it('rewrites known script / flow / app paths to link nodes with hProperties', () => {
+ const processor = buildProcessor('admins')
+ const tree = processor.runSync(
+ processor.parse(
+ 'Use f/marketing/send_email and u/admin/cleanup_old_jobs, also try f/ops/dashboard.'
+ )
+ ) as MdastRoot
+
+ const links = findLinks(tree)
+ expect(links).toHaveLength(3)
+
+ const byPath = Object.fromEntries(
+ links.map((l) => [(l.children[0] as Text).value, l])
+ ) as Record
+
+ expect(byPath['f/marketing/send_email'].url).toBe(
+ '/scripts/get/f/marketing/send_email?workspace=admins'
+ )
+ expect(byPath['u/admin/cleanup_old_jobs'].url).toBe(
+ '/flows/get/u/admin/cleanup_old_jobs?workspace=admins'
+ )
+ expect(byPath['f/ops/dashboard'].url).toBe('/apps/get/f/ops/dashboard?workspace=admins')
+
+ expect(byPath['f/marketing/send_email'].title).toBeNull()
+
+ const props = byPath['f/marketing/send_email'].data?.hProperties as Record
+ expect(props['data-wm-kind']).toBe('script')
+ expect(props['data-wm-path']).toBe('f/marketing/send_email')
+ })
+
+ it('adds target kind metadata when a drawer action needs it', () => {
+ const processor = buildProcessor('admins')
+ const tree = processor.runSync(processor.parse('Open f/etl/daily.')) as MdastRoot
+ const links = findLinks(tree)
+ expect(links).toHaveLength(1)
+ const props = links[0].data?.hProperties as Record
+ expect(props['data-wm-kind']).toBe('schedule')
+ expect(props['data-wm-path']).toBe('f/etl/daily')
+ expect(props['data-wm-target-kind']).toBe('flow')
+ })
+
+ it('leaves unknown paths as plain text', () => {
+ const processor = buildProcessor()
+ const tree = processor.runSync(
+ processor.parse('Looking for f/nope/missing or u/ghost/script')
+ ) as MdastRoot
+ expect(findLinks(tree)).toHaveLength(0)
+ const joined = findText(tree)
+ .map((t) => t.value)
+ .join('')
+ expect(joined).toContain('f/nope/missing')
+ expect(joined).toContain('u/ghost/script')
+ })
+
+ it('rewrites standalone inline-code paths into link pills', () => {
+ const processor = buildProcessor('admins')
+ const tree = processor.runSync(
+ processor.parse('Open `f/marketing/send_email` to see it.')
+ ) as MdastRoot
+ const links = findLinks(tree)
+ expect(links).toHaveLength(1)
+ expect(links[0].url).toBe('/scripts/get/f/marketing/send_email?workspace=admins')
+ expect((links[0].data?.hProperties as Record)['data-wm-kind']).toBe('script')
+ })
+
+ it('leaves inline code alone when it contains more than just a path', () => {
+ const processor = buildProcessor()
+ const tree = processor.runSync(
+ processor.parse('Like `f/marketing/send_email and friends` should stay code.')
+ ) as MdastRoot
+ expect(findLinks(tree)).toHaveLength(0)
+ })
+
+ it('does not rewrite paths inside fenced code blocks', () => {
+ const processor = buildProcessor()
+ const tree = processor.runSync(
+ processor.parse('```\nf/marketing/send_email stays in the block\n```\n')
+ ) as MdastRoot
+ expect(findLinks(tree)).toHaveLength(0)
+ })
+
+ it('leaves inline code untouched when the wrapped path is unknown', () => {
+ const processor = buildProcessor()
+ const tree = processor.runSync(processor.parse('Try `f/nope/missing` instead.')) as MdastRoot
+ expect(findLinks(tree)).toHaveLength(0)
+ })
+
+ it('does not rewrite paths inside existing links (e.g. autolinked URLs)', () => {
+ const processor = buildProcessor()
+ // Markdown-explicit link with a URL containing what looks like a Windmill path.
+ const tree = processor.runSync(
+ processor.parse('See [docs](https://example.com/f/marketing/send_email).')
+ ) as MdastRoot
+ const links = findLinks(tree)
+ expect(links).toHaveLength(1)
+ // Original docs link preserved, no synthetic Windmill link added.
+ expect(links[0].url).toBe('https://example.com/f/marketing/send_email')
+ expect(links[0].data?.hProperties).toBeUndefined()
+ })
+
+ it('handles bold / italic wrapped paths', () => {
+ const processor = buildProcessor()
+ const tree = processor.runSync(
+ processor.parse('Run **f/marketing/send_email** today, or _u/admin/cleanup_old_jobs_.')
+ ) as MdastRoot
+ const links = findLinks(tree)
+ expect(links).toHaveLength(2)
+ expect(new Set(links.map((l) => (l.children[0] as Text).value))).toEqual(
+ new Set(['f/marketing/send_email', 'u/admin/cleanup_old_jobs'])
+ )
+ })
+
+ it('preserves data attributes through remark-rehype', () => {
+ const processor = buildProcessor('admins').use(remarkRehype, { allowDangerousHtml: true })
+ const hast: any = processor.runSync(processor.parse('Use f/marketing/send_email today.'))
+ // Walk hast tree to find element.
+ const links: any[] = []
+ const walk = (node: any) => {
+ if (!node) return
+ if (node.type === 'element' && node.tagName === 'a') links.push(node)
+ if (Array.isArray(node.children)) node.children.forEach(walk)
+ }
+ walk(hast)
+ expect(links).toHaveLength(1)
+ expect(links[0].properties.href).toBe('/scripts/get/f/marketing/send_email?workspace=admins')
+ // data-* may be normalized by the mdast/hast bridge.
+ expect(links[0].properties['dataWmKind'] ?? links[0].properties['data-wm-kind']).toBe('script')
+ expect(links[0].properties['dataWmPath'] ?? links[0].properties['data-wm-path']).toBe(
+ 'f/marketing/send_email'
+ )
+ })
+})
diff --git a/frontend/src/lib/components/offboarding-utils.ts b/frontend/src/lib/components/offboarding-utils.ts
index 791d65e8d6..f6f41bcc68 100644
--- a/frontend/src/lib/components/offboarding-utils.ts
+++ b/frontend/src/lib/components/offboarding-utils.ts
@@ -77,16 +77,22 @@ export function kindLabel(kind: string): string {
export function itemHref(kind: string, path: string): string | undefined {
switch (kind) {
+ case 'script':
case 'scripts':
return `/scripts/get/${path}`
+ case 'flow':
case 'flows':
return `/flows/get/${path}`
+ case 'app':
case 'apps':
return `/apps/get/${path}`
+ case 'resource':
case 'resources':
return `/resources#/resource/${path}`
+ case 'variable':
case 'variables':
return `/variables#${path}`
+ case 'schedule':
case 'schedules':
return `/schedules#${path}`
default: {