mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
feat: open path links from chat messages in the session preview panel (#10881)
A workspace path mentioned in a chat message rendered as a link that always opened a new browser tab. On the sessions page, which hosts a preview panel, a plain click now opens the item in that panel instead. Modifier clicks still reach a new tab, and surfaces with no panel keep their previous behaviour. Scripts, flows and raw apps are supported. Legacy drag-and-drop apps are not: the panel has no editor that can host one, so their links stay outbound. The link pill's kind icon and action icon now cross-fade inside a fixed 12px box, so the pill is the same width at rest and on hover and the surrounding sentence never reflows. `openItemPreviewAction` moves to a new import-free leaf module so a chat message can reach it at runtime without dragging monaco, zod and the openai client into the render path. Claude-Session: https://claude.ai/code/session_01RjbVL7h9NiTLGTgyfiHvXG Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
17ba521c35
commit
f10ac6c2b3
@@ -19,6 +19,7 @@
|
||||
'data-wm-kind'?: WindmillItemKind
|
||||
'data-wm-path'?: string
|
||||
'data-wm-target-kind'?: WorkspaceItemTargetKind
|
||||
'data-wm-raw-app'?: string
|
||||
title?: string
|
||||
}
|
||||
let {
|
||||
@@ -27,15 +28,25 @@
|
||||
'data-wm-kind': wmKind,
|
||||
'data-wm-path': wmPath,
|
||||
'data-wm-target-kind': wmTargetKind,
|
||||
'data-wm-raw-app': wmRawApp,
|
||||
title
|
||||
}: Props = $props()
|
||||
|
||||
// The drawers ride with the docked chat, so a surface can render this pill with nothing
|
||||
// able to open one.
|
||||
const drawerAction = $derived.by(() => {
|
||||
const action = workspaceItemAction(wmKind, wmPath, wmTargetKind)
|
||||
const available = $derived.by(() => {
|
||||
const action = workspaceItemAction(wmKind, wmPath, wmTargetKind, wmRawApp === 'true')
|
||||
return action && hasToolDisplayActionHandler(action.type) ? action : undefined
|
||||
})
|
||||
// Only the preview panel takes the plain click. A drawer keeps its own button beside an
|
||||
// outbound link: the docked chat mounts drawer handlers on nearly every page, so claiming
|
||||
// that click would redirect these pills far outside the sessions page.
|
||||
const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined)
|
||||
const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined)
|
||||
|
||||
const hint = $derived(
|
||||
previewAction ? `Open ${wmPath} in the preview panel` : `Open ${wmPath} in a new tab`
|
||||
)
|
||||
|
||||
async function openDrawer(event?: Event) {
|
||||
event?.preventDefault()
|
||||
@@ -44,6 +55,14 @@
|
||||
await runToolDisplayAction(drawerAction)
|
||||
}
|
||||
}
|
||||
|
||||
async function onclick(event: MouseEvent) {
|
||||
// Modifier clicks are the only remaining route to the tab once the plain click is
|
||||
// spoken for, so leave them to the browser.
|
||||
if (!previewAction || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
await runToolDisplayAction(previewAction)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
@@ -51,20 +70,29 @@
|
||||
<span class="group inline-flex items-baseline">
|
||||
<a
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title || wmPath || href}
|
||||
target={previewAction ? undefined : '_blank'}
|
||||
rel={previewAction ? undefined : 'noopener noreferrer'}
|
||||
title={title || hint}
|
||||
{onclick}
|
||||
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} />
|
||||
<!-- Kind icon and action icon share one fixed 12px box, so the pill is the same
|
||||
width at rest and on hover and the surrounding sentence never reflows. -->
|
||||
<span class="relative inline-flex self-center shrink-0 w-3 h-3">
|
||||
<span class="absolute inset-0 transition-opacity group-hover:opacity-0">
|
||||
<RowIcon kind={wmKind} size={12} />
|
||||
</span>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-tertiary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{#if previewAction}
|
||||
<PanelRight size={12} />
|
||||
{:else}
|
||||
<ExternalLink size={11} />
|
||||
{/if}
|
||||
</span>
|
||||
</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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// The session preview panel's action, kept out of `shared.ts` so the chat message render
|
||||
// path can import it at runtime without pulling in that module's graph and risking the
|
||||
// chunk cycles docs/frontend-import-cycles.md exists to prevent. Keep this file import-free.
|
||||
|
||||
/** Item kinds a session preview can host: the three live editors, which are also the
|
||||
* subset a write tool can land. */
|
||||
export type PreviewCardKind = 'script' | 'flow' | 'raw_app'
|
||||
|
||||
// Dispatched by a preview card on a tool call that created or updated a workspace item,
|
||||
// and by a path link in a chat message. Opens the item's live editor in the session side
|
||||
// panel — or focuses the tab if it is already open. The handler is registered by the
|
||||
// sessions page (the only surface with a preview panel).
|
||||
export type OpenItemPreviewAction = {
|
||||
id: string
|
||||
type: 'open_item_preview'
|
||||
label: string
|
||||
previewKind: PreviewCardKind
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Build the action a preview card or path link dispatches from its (kind, path). */
|
||||
export function openItemPreviewAction(kind: PreviewCardKind, path: string): OpenItemPreviewAction {
|
||||
return {
|
||||
id: `open-item-preview:${kind}:${path}`,
|
||||
type: 'open_item_preview',
|
||||
label: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
previewKind: kind,
|
||||
path
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import type { UserDraftItemKind } from '$lib/gen'
|
||||
// The gate's two refusals, from a module that holds prose and one size limit: under the
|
||||
// shallow-import rule below, the rest of plan mode is not reachable from here.
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
// Import-free leaf, so it satisfies the shallow-import rule below.
|
||||
import {
|
||||
openItemPreviewAction,
|
||||
type OpenItemPreviewAction,
|
||||
type PreviewCardKind
|
||||
} from './itemPreview'
|
||||
|
||||
// The tool modules that import this one (workspaceTools, flow/core, global/core, ...)
|
||||
// call createToolDef and read SPECIAL_MODULE_IDS at *module scope*, so if a chunk cycle
|
||||
@@ -526,35 +532,11 @@ export type NavigateAction = {
|
||||
page: string
|
||||
}
|
||||
|
||||
/** Kinds of previewable item a write tool can land — the subset of draft item
|
||||
* kinds a session preview can host. */
|
||||
export type PreviewCardKind = 'script' | 'flow' | 'raw_app'
|
||||
|
||||
// A discrete card shown on a tool call that created or updated a workspace item.
|
||||
// Clicking it opens the item's live preview in the session side panel — or focuses
|
||||
// the tab if it is already open. The handler is registered by the sessions page
|
||||
// (the only surface with a preview panel).
|
||||
export type OpenItemPreviewAction = {
|
||||
id: string
|
||||
type: 'open_item_preview'
|
||||
label: string
|
||||
previewKind: PreviewCardKind
|
||||
path: string
|
||||
}
|
||||
// Re-exported: most consumers reach these through this module.
|
||||
export { openItemPreviewAction, type PreviewCardKind, type OpenItemPreviewAction }
|
||||
|
||||
export type ToolDisplayAction = CreatedResourceAction | NavigateAction | OpenItemPreviewAction
|
||||
|
||||
/** Build the action a preview card dispatches from its (kind, path). */
|
||||
export function openItemPreviewAction(kind: PreviewCardKind, path: string): OpenItemPreviewAction {
|
||||
return {
|
||||
id: `open-item-preview:${kind}:${path}`,
|
||||
type: 'open_item_preview',
|
||||
label: `Open ${kind === 'raw_app' ? 'app' : kind} preview`,
|
||||
previewKind: kind,
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
export type UserQuestionDisplay = {
|
||||
question: string
|
||||
choices: string[]
|
||||
|
||||
@@ -22,6 +22,8 @@ import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import type { Root, InlineCode, Link } from 'mdast'
|
||||
import type { CreatedResourceAction, ToolDisplayAction } from './shared'
|
||||
// Leaf module, deliberately not './shared' — see the header of itemPreview.ts.
|
||||
import { openItemPreviewAction } from './itemPreview'
|
||||
|
||||
export type WindmillItemKind =
|
||||
| 'script'
|
||||
@@ -46,6 +48,9 @@ export interface WorkspaceItemEntry {
|
||||
kind: WindmillItemKind
|
||||
path: string
|
||||
targetKind?: WorkspaceItemTargetKind
|
||||
/** Apps only. Both kinds share the `app` kind and the same `/apps/get/<path>` route,
|
||||
* so this flag is all that tells them apart downstream. */
|
||||
rawApp?: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceItemTargetKind = 'script' | 'flow'
|
||||
@@ -96,6 +101,7 @@ export function itemHref(entry: WorkspaceItemEntry, workspace?: string): string
|
||||
type WorkspaceItemListResult = Array<{
|
||||
path: string
|
||||
is_flow?: boolean | null
|
||||
raw_app?: boolean | null
|
||||
}>
|
||||
|
||||
const workspaceItemLoaders: Array<{
|
||||
@@ -165,7 +171,8 @@ class WorkspaceItemRegistry {
|
||||
kind,
|
||||
path: it.path,
|
||||
targetKind:
|
||||
typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined
|
||||
typeof it.is_flow === 'boolean' ? (it.is_flow ? 'flow' : 'script') : undefined,
|
||||
rawApp: kind === 'app' ? it.raw_app === true : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -213,13 +220,30 @@ export function extractCandidatePaths(text: string | undefined | null): string[]
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-app action a resolved path link runs instead of opening a new tab, or undefined
|
||||
* when the kind has none (its link then stays outbound). Returning an action does not mean
|
||||
* it can run on this surface — only the sessions page hosts a preview panel, so callers
|
||||
* gate on `hasToolDisplayActionHandler(action.type)`.
|
||||
*/
|
||||
export function workspaceItemAction(
|
||||
kind: WindmillItemKind | undefined,
|
||||
path: string | undefined,
|
||||
targetKind?: WorkspaceItemTargetKind
|
||||
targetKind?: WorkspaceItemTargetKind,
|
||||
rawApp?: boolean
|
||||
): ToolDisplayAction | undefined {
|
||||
if (!kind || !path) return undefined
|
||||
|
||||
if (kind === 'script' || kind === 'flow') {
|
||||
return openItemPreviewAction(kind, path)
|
||||
}
|
||||
|
||||
// Raw apps only. A legacy drag-and-drop app has no editor the panel can host, and
|
||||
// legacy items are not extended onto new surfaces — its link stays outbound.
|
||||
if (kind === 'app') {
|
||||
return rawApp ? openItemPreviewAction('raw_app', path) : undefined
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: `open_workspace_item:${kind}:${path}`,
|
||||
type: 'open_created_resource' as const,
|
||||
@@ -253,6 +277,9 @@ function buildPathLinkNode(
|
||||
if (entry.targetKind) {
|
||||
hProperties['data-wm-target-kind'] = entry.targetKind
|
||||
}
|
||||
if (entry.rawApp) {
|
||||
hProperties['data-wm-raw-app'] = 'true'
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link',
|
||||
|
||||
@@ -153,10 +153,27 @@ describe('workspaceItemAction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
it('creates preview actions for scripts, flows and raw apps', () => {
|
||||
expect(workspaceItemAction('script', 'f/a/b')).toMatchObject({
|
||||
type: 'open_item_preview',
|
||||
previewKind: 'script',
|
||||
path: 'f/a/b'
|
||||
})
|
||||
expect(workspaceItemAction('flow', 'f/a/b')).toMatchObject({
|
||||
type: 'open_item_preview',
|
||||
previewKind: 'flow'
|
||||
})
|
||||
expect(workspaceItemAction('app', 'f/a/b', undefined, true)).toMatchObject({
|
||||
previewKind: 'raw_app'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves legacy apps as plain links', () => {
|
||||
expect(workspaceItemAction('app', 'f/a/b', undefined, false)).toBeUndefined()
|
||||
expect(workspaceItemAction('app', 'f/a/b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips trigger items without target kind', () => {
|
||||
expect(workspaceItemAction('schedule', 'f/a/b')).toBeUndefined()
|
||||
expect(workspaceItemAction('http_trigger', 'f/a/b')).toBeUndefined()
|
||||
})
|
||||
@@ -172,6 +189,7 @@ const SAMPLE_ENTRIES: Record<string, WorkspaceItemEntry> = {
|
||||
path: 'u/admin/cleanup_old_jobs'
|
||||
},
|
||||
'f/ops/dashboard': { kind: 'app', path: 'f/ops/dashboard' },
|
||||
'f/ops/live_board': { kind: 'app', path: 'f/ops/live_board', rawApp: true },
|
||||
'f/etl/daily': {
|
||||
kind: 'schedule',
|
||||
path: 'f/etl/daily',
|
||||
@@ -249,6 +267,23 @@ describe('remarkWindmillPaths (mdast)', () => {
|
||||
expect(props['data-wm-target-kind']).toBe('flow')
|
||||
})
|
||||
|
||||
// Both app kinds reach the renderer as the same `app` kind and route, so this flag is
|
||||
// the only thing that keeps a legacy app off the preview panel.
|
||||
it('marks raw apps so the renderer can tell them from legacy apps', () => {
|
||||
const processor = buildProcessor('admins')
|
||||
const tree = processor.runSync(
|
||||
processor.parse('Open f/ops/live_board and f/ops/dashboard.')
|
||||
) as MdastRoot
|
||||
const byPath = Object.fromEntries(
|
||||
findLinks(tree).map((l) => [
|
||||
(l.data?.hProperties as Record<string, string>)['data-wm-path'],
|
||||
l.data?.hProperties as Record<string, string>
|
||||
])
|
||||
)
|
||||
expect(byPath['f/ops/live_board']['data-wm-raw-app']).toBe('true')
|
||||
expect(byPath['f/ops/dashboard']['data-wm-raw-app']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves unknown paths as plain text', () => {
|
||||
const processor = buildProcessor()
|
||||
const tree = processor.runSync(
|
||||
|
||||
@@ -591,9 +591,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Preview cards on create/update tool calls dispatch here. Open
|
||||
// (or focus, if already shown) the item's preview in the active session's panel —
|
||||
// the visible chat is always the active session, so `owner` is its panel. Read
|
||||
// The visible chat is always the active session, so `owner` is its panel. Read
|
||||
// `owner` lazily inside the handler (not in the effect body) so this registers
|
||||
// once, not on every session switch.
|
||||
$effect(() => {
|
||||
|
||||
Reference in New Issue
Block a user