mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
feat(ai-sessions): show item preview cards for tools (#10254)
* feat(ai-sessions): show item preview cards for create/update/open-preview tools Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-sessions): only show open_preview card when the preview actually opened Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ai-sessions): render preview card as a chip on the tool-call header row Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d2c5d6f4b4
commit
703744fb8b
@@ -9,6 +9,7 @@
|
||||
import { slide } from 'svelte/transition'
|
||||
import ToolContentDisplay from './ToolContentDisplay.svelte'
|
||||
import ToolMessageActions from './ToolMessageActions.svelte'
|
||||
import ToolPreviewCard from './ToolPreviewCard.svelte'
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
@@ -50,6 +51,14 @@
|
||||
const activeUserQuestion = $derived(
|
||||
isActiveUserQuestion(message) ? message.userQuestion : undefined
|
||||
)
|
||||
|
||||
// The preview chip sits on the header row (to the right of the tool-call text);
|
||||
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
|
||||
const showPreviewChip = $derived(
|
||||
Boolean(
|
||||
message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if activeUserQuestion}
|
||||
@@ -57,32 +66,48 @@
|
||||
{:else}
|
||||
<div class="font-mono text-xs">
|
||||
<!-- Collapsible Header -->
|
||||
<button
|
||||
class={twMerge(
|
||||
'py-0.5 my-0.5 rounded-md hover:bg-surface-hover transition-colors inline-flex items-center text-left',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!detailsAvailable && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
|
||||
{/if}
|
||||
<span class="text-primary font-medium text-2xs">
|
||||
{message.content}
|
||||
</span>
|
||||
{#snippet headerButton()}
|
||||
<button
|
||||
class={twMerge(
|
||||
'min-w-0 py-0.5 my-0.5 rounded-md hover:bg-surface-hover transition-colors inline-flex items-center text-left',
|
||||
message.needsConfirmation ? 'opacity-80' : ''
|
||||
)}
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
disabled={!detailsAvailable && !message.isStreamingArguments}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if message.isLoading && !message.needsConfirmation}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge('text-primary font-medium text-2xs', showPreviewChip ? 'truncate' : '')}
|
||||
>
|
||||
{message.content}
|
||||
</span>
|
||||
|
||||
{#if detailsAvailable || message.isStreamingArguments}
|
||||
<ChevronRight
|
||||
class={twMerge(
|
||||
'w-3 h-3 text-secondary transition-transform duration-150',
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{#if detailsAvailable || message.isStreamingArguments}
|
||||
<ChevronRight
|
||||
class={twMerge(
|
||||
'w-3 h-3 text-secondary transition-transform duration-150 shrink-0',
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<!-- Discrete preview chip for an item a tool created/updated/opened, pinned to
|
||||
the right of the header row. Rendered inline (not gated on expand) so it
|
||||
stays visible after the tool collapses. -->
|
||||
{#if showPreviewChip && message.previewCard}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{@render headerButton()}
|
||||
<ToolPreviewCard card={message.previewCard} />
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
{@render headerButton()}
|
||||
{/if}
|
||||
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
{#if message.imageUrl}
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
const config = navigatePageConfig[action.page] ?? { title: action.page, icon: Route }
|
||||
return { ...config, subtitle: action.label, buttonIcon: ArrowRight }
|
||||
}
|
||||
if (action.type === 'open_item_preview') {
|
||||
// Preview-item cards render through ToolPreviewCard, not here; keep this branch
|
||||
// only so the exhaustive union stays type-safe.
|
||||
return { title: action.label, icon: SquarePen, subtitle: action.path, buttonIcon: ArrowRight }
|
||||
}
|
||||
const key: ActionCardKey | undefined =
|
||||
action.resource === 'trigger' ? action.triggerKind : action.resource
|
||||
const config = key
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { PanelRight } from 'lucide-svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import { runToolDisplayAction } from './createdResourceActions.svelte'
|
||||
import { openItemPreviewAction, type PreviewCardKind } from './shared'
|
||||
|
||||
interface Props {
|
||||
card: { kind: PreviewCardKind; path: string }
|
||||
}
|
||||
|
||||
let { card }: Props = $props()
|
||||
|
||||
// RowIcon has no 'pipeline' kind — a pipeline is a folder graph, shown with the
|
||||
// data-pipeline icon.
|
||||
const iconKind = $derived(card.kind === 'pipeline' ? 'data_pipeline' : card.kind)
|
||||
const kindLabel = $derived(
|
||||
card.kind === 'raw_app' ? 'app' : card.kind === 'pipeline' ? 'pipeline' : card.kind
|
||||
)
|
||||
|
||||
let opening = $state(false)
|
||||
async function open() {
|
||||
if (opening) return
|
||||
opening = true
|
||||
try {
|
||||
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
|
||||
} finally {
|
||||
opening = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={open}
|
||||
disabled={opening}
|
||||
title="Open {kindLabel} preview: {card.path}"
|
||||
class="group shrink-0 inline-flex items-center gap-1.5 rounded-md border border-light bg-surface pl-1.5 pr-2 py-1 transition-colors hover:bg-surface-hover disabled:opacity-60"
|
||||
>
|
||||
<span class="inline-flex shrink-0">
|
||||
<RowIcon kind={iconKind} size={12} />
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-2xs text-tertiary group-hover:text-secondary">
|
||||
Preview <PanelRight size={11} />
|
||||
</span>
|
||||
</button>
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
executeTestRun,
|
||||
findAndReplace,
|
||||
type CreatedResourceTriggerKind,
|
||||
type PreviewCardKind,
|
||||
type Tool,
|
||||
type ToolCallbacks,
|
||||
type ToolDisplayAction
|
||||
@@ -3259,7 +3260,18 @@ export const globalTools: Tool<{}>[] = [
|
||||
),
|
||||
fn: async (ctx) => {
|
||||
const parsed = openPreviewSchema.parse(ctx.args)
|
||||
return openSessionPreview(parsed, sessionIdFromCtx(ctx))
|
||||
const sessionId = sessionIdFromCtx(ctx)
|
||||
const { opened, message } = openSessionPreview(parsed, sessionId)
|
||||
// Surface the same discrete card the write tools show, so the user can
|
||||
// re-open/focus the preview later from the tool call. Only when the tool
|
||||
// actually opened the preview (not for a refused open, e.g. a gated
|
||||
// pipeline), and only in a session.
|
||||
if (sessionId && opened) {
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, {
|
||||
previewCard: { kind: parsed.kind, path: parsed.path }
|
||||
})
|
||||
}
|
||||
return message
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3548,18 +3560,25 @@ export function setOpenPreviewHandler(handler: OpenPreviewHandler | undefined):
|
||||
openPreviewHandler = handler
|
||||
}
|
||||
|
||||
// `opened` distinguishes a real open (the caller may then offer a preview card)
|
||||
// from a refusal that is returned as a message rather than thrown — so the card is
|
||||
// never shown for an item the tool declined to preview (e.g. a gated pipeline).
|
||||
function openSessionPreview(
|
||||
args: { kind: 'script' | 'flow' | 'raw_app' | 'pipeline'; path: string },
|
||||
sessionId: string | undefined
|
||||
) {
|
||||
): { opened: boolean; message: string } {
|
||||
if (!openPreviewHandler) {
|
||||
return 'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
return {
|
||||
opened: false,
|
||||
message:
|
||||
'Error: open_preview is only available inside an AI session. Tell the user to switch to a session to view the preview, or describe the item textually.'
|
||||
}
|
||||
}
|
||||
// open_preview only exists in sessions, so no sessionId check is needed here.
|
||||
if (args.kind === 'pipeline' && !isSessionPipelinesEnabled()) {
|
||||
return SESSION_PIPELINES_GATED_MESSAGE
|
||||
return { opened: false, message: SESSION_PIPELINES_GATED_MESSAGE }
|
||||
}
|
||||
return openPreviewHandler({ ...args, sessionId })
|
||||
return { opened: true, message: openPreviewHandler({ ...args, sessionId }) }
|
||||
}
|
||||
|
||||
// Opens a workspace *page* (Runs, Schedules, …) as a page tab in the session's
|
||||
@@ -3912,6 +3931,32 @@ function draftWriteFailure(result: DraftPersistResult, ctx: WriteDraftCtx): stri
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Item kinds a session preview can host, keyed by the draft item kind a write
|
||||
// resolves to. Kinds absent here (resources, variables, triggers, legacy `app`)
|
||||
// have no preview panel, so no card is offered for them.
|
||||
const PREVIEW_CARD_KIND_BY_ITEM_KIND: Partial<
|
||||
Record<DraftPersistResult['itemKind'], PreviewCardKind>
|
||||
> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
raw_app: 'raw_app'
|
||||
}
|
||||
|
||||
// Offer a preview card for a write that landed a previewable item. Session chats
|
||||
// only (`ctx.sessionId`): the card opens the item in the side panel, which the
|
||||
// global side-panel chat has no equivalent of. `path` is the item's display path
|
||||
// (what `open_preview` takes), not its synthetic draft storage key.
|
||||
function maybeAttachPreviewCard(
|
||||
ctx: WriteDraftCtx,
|
||||
itemKind: DraftPersistResult['itemKind'],
|
||||
path: string
|
||||
): void {
|
||||
if (!ctx.sessionId) return
|
||||
const kind = PREVIEW_CARD_KIND_BY_ITEM_KIND[itemKind]
|
||||
if (!kind) return
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, { previewCard: { kind, path } })
|
||||
}
|
||||
|
||||
// App write tools build varied success messages but share the same conflict /
|
||||
// save-failure handling; `onSaved` supplies the per-tool status + message.
|
||||
function finishAppDraftWrite(
|
||||
@@ -3922,6 +3967,7 @@ function finishAppDraftWrite(
|
||||
const failure = draftWriteFailure(result, ctx)
|
||||
if (failure) return failure
|
||||
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
|
||||
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
|
||||
const { content, message } = onSaved()
|
||||
ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' })
|
||||
return JSON.stringify({ success: true, message }, null, 2)
|
||||
@@ -3935,6 +3981,7 @@ function finishDraftWrite(
|
||||
const failure = draftWriteFailure(result, ctx)
|
||||
if (failure) return failure
|
||||
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
|
||||
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
|
||||
const stored = result.item
|
||||
const verb = existed ? 'Updated' : 'Created'
|
||||
// Don't echo the flow value back: the model just sent it in the write call,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { DisplayMessage, ToolDisplayMessage } from './shared'
|
||||
import { openItemPreviewAction } from './shared'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {}
|
||||
@@ -973,3 +974,23 @@ describe('appendPendingToolImages', () => {
|
||||
expect(addedMessages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openItemPreviewAction', () => {
|
||||
// The action's `type` is the key the sessions page registers its handler under,
|
||||
// so it must stay 'open_item_preview'; `previewKind`/`path` are passed verbatim
|
||||
// to previewTargetForSessionTarget.
|
||||
it('carries the kind and path through to the dispatch action', () => {
|
||||
expect(openItemPreviewAction('flow', 'f/team/etl')).toEqual({
|
||||
id: 'open-item-preview:flow:f/team/etl',
|
||||
type: 'open_item_preview',
|
||||
label: 'Open flow preview',
|
||||
previewKind: 'flow',
|
||||
path: 'f/team/etl'
|
||||
})
|
||||
})
|
||||
|
||||
// raw_app is the internal kind; the user-facing label says "app".
|
||||
it('labels raw_app as "app"', () => {
|
||||
expect(openItemPreviewAction('raw_app', 'u/me/dash').label).toBe('Open app preview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -515,7 +515,34 @@ export type NavigateAction = {
|
||||
page: string
|
||||
}
|
||||
|
||||
export type ToolDisplayAction = CreatedResourceAction | NavigateAction
|
||||
/** Kinds of item a session preview can host — the subset the `open_preview` tool
|
||||
* accepts. `pipeline` targets a folder graph, the rest a workspace item path. */
|
||||
export type PreviewCardKind = 'script' | 'flow' | 'raw_app' | 'pipeline'
|
||||
|
||||
// A discrete card shown under a tool call that created/updated/opened the preview of
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -559,6 +586,10 @@ export type ToolDisplayMessage = {
|
||||
webSearchSources?: WebSearchSource[]
|
||||
/** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */
|
||||
imageUrl?: string
|
||||
/** Workspace item this tool created/updated or opened a preview of. Rendered as a
|
||||
* discrete, always-visible card that opens (or focuses) the item's preview in the
|
||||
* session side panel. Set only for session chats — the side panel is their surface. */
|
||||
previewCard?: { kind: PreviewCardKind; path: string }
|
||||
}
|
||||
|
||||
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
import { markSessionSeen } from '$lib/components/sessions/sessionUnread.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { setToolCompletionListener } from '$lib/components/copilot/chat/shared'
|
||||
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
|
||||
import { previewTargetForSessionTarget } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
artifactKey,
|
||||
@@ -458,6 +460,24 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Preview cards under create/update/open-preview 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
|
||||
// `owner` lazily inside the handler (not in the effect body) so this registers
|
||||
// once, not on every session switch. A 'focused' open leaves the tab where it is,
|
||||
// so pulse it to make the click visibly land.
|
||||
$effect(() => {
|
||||
return registerToolDisplayActionHandler('open_item_preview', (action) => {
|
||||
if (action.type !== 'open_item_preview') return
|
||||
const o = owner
|
||||
if (!o) return
|
||||
const target = previewTargetForSessionTarget(action.previewKind, action.path)
|
||||
if (!target) return
|
||||
const { status } = o.open(target)
|
||||
if (status === 'focused') o.pulseFocus(o.activeId)
|
||||
})
|
||||
})
|
||||
|
||||
// Editor-style breadcrumb over the previewed page. We only render clickable
|
||||
// segments when the preview is sitting on a script/flow/app route — for any
|
||||
// other page (home, runs, …) there's no item to drill into, so we fall back
|
||||
|
||||
Reference in New Issue
Block a user