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:
Ruben Fiszel
2026-07-22 11:47:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d2c5d6f4b4
commit 703744fb8b
7 changed files with 224 additions and 30 deletions
@@ -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