mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8a145967b9
commit
d4223789e8
Generated
+1
@@ -72,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",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Code2, LayoutDashboard } from 'lucide-svelte'
|
||||
import { Code2, ExternalLink, LayoutDashboard } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { WindmillItemKind } from './workspaceItems.svelte'
|
||||
|
||||
@@ -32,18 +32,23 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || href}
|
||||
class="inline-flex items-baseline gap-1 px-1 rounded bg-surface-secondary hover:bg-surface-hover border border-gray-200 dark:border-gray-700 text-primary no-underline font-mono text-[0.9em] align-baseline"
|
||||
class="group 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 text-secondary">
|
||||
<span class="inline-flex self-center shrink-0">
|
||||
{#if kind === 'script'}
|
||||
<Code2 size={12} />
|
||||
<Code2 size={12} class="text-blue-500" />
|
||||
{:else if kind === 'flow'}
|
||||
<BarsStaggered size={12} style="" class="!fill-current" />
|
||||
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
|
||||
{:else if kind === 'app'}
|
||||
<LayoutDashboard size={12} />
|
||||
<LayoutDashboard size={12} class="text-orange-500" />
|
||||
{/if}
|
||||
</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>
|
||||
{:else}
|
||||
<a {href} target="_blank" rel="noopener noreferrer" {title}>
|
||||
|
||||
@@ -108,51 +108,59 @@
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!message.showDetails && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<div class="flex items-start gap-2 flex-1 min-w-0">
|
||||
{#if message.showDetails || message.isStreamingArguments}
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-3 h-3 text-secondary" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3 text-secondary" />
|
||||
{/if}
|
||||
<span class="shrink-0 mt-0.5">
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="w-3 h-3 text-secondary" />
|
||||
{:else}
|
||||
<ChevronRight class="w-3 h-3 text-secondary" />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{:else if message.error}
|
||||
<span class="text-red-500">✗</span>
|
||||
{:else if !message.isLoading && !message.error}
|
||||
<span class="text-green-500">✓</span>
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
<span class="shrink-0 mt-0.5">
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{:else if message.error}
|
||||
<span class="text-red-500">✗</span>
|
||||
{:else if !message.isLoading && !message.error}
|
||||
<span class="text-green-500">✓</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if referencedItems.length > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 ml-1 min-w-0">
|
||||
{#each referencedItems as item (item.path)}
|
||||
<a
|
||||
href={itemHref(item, $workspaceStore ?? undefined)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
title={item.summary || item.path}
|
||||
class="inline-flex items-center gap-1 px-1 py-0.5 rounded bg-surface-secondary hover:bg-surface-hover border border-gray-200 dark:border-gray-700 text-primary no-underline font-mono text-2xs max-w-[14rem] truncate"
|
||||
>
|
||||
<span class="inline-flex shrink-0 text-secondary">
|
||||
{#if item.kind === 'script'}
|
||||
<Code2 class="w-3 h-3" />
|
||||
{:else if item.kind === 'flow'}
|
||||
<BarsStaggered size={12} style="" class="!fill-current" />
|
||||
{:else}
|
||||
<LayoutDashboard class="w-3 h-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{item.path}</span>
|
||||
<ExternalLink class="w-2.5 h-2.5 shrink-0 text-tertiary" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
</span>
|
||||
{#if referencedItems.length > 0}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 min-w-0">
|
||||
{#each referencedItems as item (item.path)}
|
||||
<a
|
||||
href={itemHref(item, $workspaceStore ?? undefined)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
title={item.summary || item.path}
|
||||
class="group inline-flex items-center gap-1 px-1 py-0.5 rounded hover:bg-surface-hover text-primary no-underline font-mono text-2xs max-w-full min-w-0"
|
||||
>
|
||||
<span class="inline-flex shrink-0">
|
||||
{#if item.kind === 'script'}
|
||||
<Code2 class="w-3 h-3 text-blue-500" />
|
||||
{:else if item.kind === 'flow'}
|
||||
<BarsStaggered size={12} style="" class="!fill-current text-teal-500" />
|
||||
{:else}
|
||||
<LayoutDashboard class="w-3 h-3 text-orange-500" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{item.path}</span>
|
||||
<ExternalLink
|
||||
class="w-2.5 h-2.5 shrink-0 text-tertiary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
import type { Root } from 'mdast'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, InlineCode, Link } from 'mdast'
|
||||
|
||||
export type WindmillItemKind = 'script' | 'flow' | 'app'
|
||||
|
||||
@@ -23,6 +24,12 @@ export interface WorkspaceItemEntry {
|
||||
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_\-]$/
|
||||
|
||||
const itemKindToRoute: Record<WindmillItemKind, string> = {
|
||||
script: '/scripts/get',
|
||||
flow: '/flows/get',
|
||||
@@ -144,12 +151,41 @@ export function resolveMentionedItems(
|
||||
return [...seen.values()]
|
||||
}
|
||||
|
||||
/** Build the link node used to replace a resolved path token. */
|
||||
function buildPathLinkNode(
|
||||
entry: WorkspaceItemEntry,
|
||||
displayPath: string,
|
||||
workspace: string | undefined
|
||||
): Link {
|
||||
return {
|
||||
type: 'link',
|
||||
url: itemHref(entry, workspace),
|
||||
title: entry.summary || null,
|
||||
data: {
|
||||
hProperties: {
|
||||
'data-wm-kind': entry.kind,
|
||||
'data-wm-path': entry.path,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
},
|
||||
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.
|
||||
*
|
||||
* Skips text inside link / inline code / code nodes (find-and-replace only visits Text nodes,
|
||||
* which already excludes inlineCode/code; the explicit `ignore` covers links).
|
||||
* 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
|
||||
@@ -163,23 +199,19 @@ export function remarkWindmillPaths(options: {
|
||||
(_match: string, path: string) => {
|
||||
const entry = options.resolve(path)
|
||||
if (!entry) return false
|
||||
return {
|
||||
type: 'link',
|
||||
url: itemHref(entry, options.workspace),
|
||||
title: entry.summary || null,
|
||||
data: {
|
||||
hProperties: {
|
||||
'data-wm-kind': entry.kind,
|
||||
'data-wm-path': entry.path,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
},
|
||||
children: [{ type: 'text', value: path }]
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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() },
|
||||
@@ -6,7 +10,13 @@ vi.mock('$lib/gen', () => ({
|
||||
AppService: { listApps: vi.fn() }
|
||||
}))
|
||||
|
||||
import { extractCandidatePaths, WINDMILL_PATH_REGEX } from './workspaceItems.svelte'
|
||||
import {
|
||||
extractCandidatePaths,
|
||||
itemHref,
|
||||
remarkWindmillPaths,
|
||||
WINDMILL_PATH_REGEX,
|
||||
type WorkspaceItemEntry
|
||||
} from './workspaceItems.svelte'
|
||||
|
||||
describe('WINDMILL_PATH_REGEX', () => {
|
||||
it('matches simple folder and user paths', () => {
|
||||
@@ -50,3 +60,182 @@ describe('WINDMILL_PATH_REGEX', () => {
|
||||
expect(WINDMILL_PATH_REGEX.global).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemHref', () => {
|
||||
it('routes by kind and appends ?workspace when provided', () => {
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_ENTRIES: Record<string, WorkspaceItemEntry> = {
|
||||
'f/marketing/send_email': {
|
||||
kind: 'script',
|
||||
path: 'f/marketing/send_email',
|
||||
summary: 'Send marketing email'
|
||||
},
|
||||
'u/admin/cleanup_old_jobs': {
|
||||
kind: 'flow',
|
||||
path: 'u/admin/cleanup_old_jobs',
|
||||
summary: 'Cleanup old jobs'
|
||||
},
|
||||
'f/ops/dashboard': { kind: 'app', path: 'f/ops/dashboard', summary: 'Ops dashboard' }
|
||||
}
|
||||
|
||||
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).toBe('Send marketing email')
|
||||
|
||||
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')
|
||||
expect(props.target).toBe('_blank')
|
||||
expect(props.rel).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
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')
|
||||
// hast normalizes target/rel as standard attrs; data-* stays kebab-case.
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user