feat: open ai chat path links in drawers (#9220)

* feat(ai-chat): link workspace paths and show tool item references

Detect Windmill paths (u/..., f/...) in assistant messages and render
them as clickable pills with the right icon, resolved against a per-
workspace cache. Tool execution headers now list the script/flow/app
paths referenced in tool parameters as external links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): linkify inline-code paths, refine pill styling

- Inline-code spans whose value is exactly a Windmill path now render
  as a link pill (paths inside larger inline code or fenced blocks
  stay as code).
- Tool-header chips moved to their own row to avoid overflow clipping
  when the title wraps.
- Borderless pills, no default background (hover only), kind icons
  use the home-page palette (script blue, flow teal, app orange),
  and the external-link indicator only appears on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): linkify variables/resources/triggers + inline drawer

- Workspace item registry now also lists variables, resources, schedules,
  and all 10 trigger kinds; resource wins over variable on path collisions
  (Windmill auto-creates a companion variable for every resource).
- Pill icons delegated to the canonical RowIcon component so each kind
  matches the home-page styling (script blue, flow teal, app orange,
  resource boxes, schedule calendar, etc.).
- Pill href includes the hash fragment each list page already consumes
  (#/resource/<path>, #<path> for variables/schedules/triggers), so
  opening the link puts the user on the list page with the matching
  editor drawer already open.
- For variable and resource pills, a hover-revealed side-panel button
  opens (or toggles closed) the editor drawer inline next to the chat,
  without navigating away. VariableEditor and ResourceEditorDrawer gain
  a closeDrawer() export and forward their close event so the host can
  drive toggling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: simplify ai chat workspace item links

* refactor: keep ai chat path linkification only

* perf: avoid eager ai chat path cache loads

* refactor: simplify ai chat path linking

* feat: open ai chat path links in drawers

* refactor: homogenize workspace item kinds

* fix: toggle ai chat item drawer

* refactor: trim ai chat path cache

* fix: cancel ai chat drawer reopen

---------

Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
centdix
2026-05-20 12:00:16 +02:00
committed by GitHub
parent cc141effa3
commit f6fcdb5599
10 changed files with 794 additions and 18 deletions
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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
]
})
</script>
<div
@@ -20,16 +64,5 @@
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
>
<Markdown
md={message.content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay,
a: LinkRenderer
}
}
]}
/>
<Markdown md={message.content} {plugins} />
</div>
@@ -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()
@@ -1,15 +1,81 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { ExternalLink, PanelRight } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { runToolDisplayAction } from './createdResourceActions.svelte'
import {
workspaceItemAction,
type WindmillItemKind,
type WorkspaceItemTargetKind
} from './workspaceItems.svelte'
type Props = {
href?: string
children?: Snippet
'data-wm-kind'?: WindmillItemKind
'data-wm-path'?: string
'data-wm-target-kind'?: WorkspaceItemTargetKind
title?: string
}
let {
href,
children,
'data-wm-kind': wmKind,
'data-wm-path': wmPath,
'data-wm-target-kind': wmTargetKind,
title
}: Props = $props()
const drawerAction = $derived(workspaceItemAction(wmKind, wmPath, wmTargetKind))
async function openDrawer(event?: Event) {
event?.preventDefault()
event?.stopPropagation()
if (drawerAction) {
await runToolDisplayAction(drawerAction)
}
}
let { href, children }: Props = $props()
</script>
{#if href}
<a {href} target="_blank" rel="noopener noreferrer">
{@render children?.()}
</a>
{#if wmKind}
<span class="group inline-flex items-baseline">
<a
{href}
target="_blank"
rel="noopener noreferrer"
title={title || wmPath || href}
class="inline-flex items-baseline gap-1 px-1 rounded hover:bg-surface-hover text-primary no-underline font-mono text-[0.9em] align-baseline"
>
<span class="inline-flex self-center shrink-0">
<RowIcon kind={wmKind} size={12} />
</span>
{@render children?.()}
<span
class="inline-flex self-center shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink size={10} />
</span>
</a>
{#if drawerAction}
<Button
type="button"
size="xs3"
variant="subtle"
iconOnly
startIcon={{ icon: PanelRight }}
title="Open in drawer"
aria-label="Open {wmPath} in drawer"
wrapperClasses="ml-0.5 inline-flex self-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
btnClasses="!w-auto !rounded !p-0.5 !text-tertiary"
onClick={openDrawer}
/>
{/if}
</span>
{:else}
<a {href} target="_blank" rel="noopener noreferrer" {title}>
{@render children?.()}
</a>
{/if}
{/if}
@@ -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 {
@@ -466,6 +466,7 @@ export type CreatedResourceTriggerKind =
| 'sqs'
| 'gcp'
| 'azure'
| 'email'
export type CreatedResourceAction = {
id: string
@@ -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/<owner>/<path>` or `f/<folder>/<path>`.
*
* 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 =
/(?<![A-Za-z0-9/_.\-])([uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-])/g
/**
* Anchored variant of {@link WINDMILL_PATH_REGEX} for use against a whole string —
* matches when the entire input is exactly a path (after trimming).
*/
const WINDMILL_PATH_EXACT_REGEX = /^[uf]\/[A-Za-z0-9_.\-]+\/[A-Za-z0-9_./\-]*[A-Za-z0-9_\-]$/
function workspaceItemTriggerKind(kind: WindmillItemKind): ToolDisplayAction['triggerKind'] {
if (!kind.endsWith('_trigger')) return undefined
return kind.slice(0, -'_trigger'.length) as ToolDisplayAction['triggerKind']
}
/**
* Build the in-app URL for a resolved workspace item.
*
* The workspace query param goes before the hash so the SvelteKit router still applies
* it; the hash fragment is preserved for destination pages that consume it.
*/
export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string {
const raw = offboardingItemHref(entry.kind, entry.path) ?? '#'
if (!workspace) return raw
const hashIdx = raw.indexOf('#')
if (hashIdx === -1) {
return raw.includes('?') ? `${raw}&workspace=${workspace}` : `${raw}?workspace=${workspace}`
}
const pathPart = raw.slice(0, hashIdx)
const hashPart = raw.slice(hashIdx)
const sep = pathPart.includes('?') ? '&' : '?'
return `${pathPart}${sep}workspace=${workspace}${hashPart}`
}
type WorkspaceItemListResult = Array<{
path: string
is_flow?: boolean | null
}>
const workspaceItemLoaders: Array<{
kind: WindmillItemKind
list: (workspace: string) => Promise<WorkspaceItemListResult>
}> = [
// 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<string, Map<string, WorkspaceItemEntry>> = $state(new Map())
#inflight: Map<string, Promise<void>> = new Map()
private async load(workspace: string): Promise<void> {
const loadedItems = await Promise.all(
workspaceItemLoaders.map(async ({ kind, list }) => ({
kind,
items: await list(workspace).catch(() => [])
}))
)
const map = new Map<string, WorkspaceItemEntry>()
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<void> {
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<string>()
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<string, string> = {
'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
})
}
}
@@ -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<string, WorkspaceItemEntry> = {
'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<string, Link>
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<string, string>
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<string, string>
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<string, string>)['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 <a> 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'
)
})
})
@@ -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: {