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:
Guilhem Lemouel
2026-05-15 10:01:46 +02:00
co-authored by Claude Opus 4.7
parent 8a145967b9
commit d4223789e8
6 changed files with 301 additions and 65 deletions
+1
View File
@@ -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",
+1
View File
@@ -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'
)
})
})