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:
AlexRV12
2026-09-02 19:30:54 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 17ba521c35
commit f10ac6c2b3
6 changed files with 146 additions and 46 deletions
@@ -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(() => {