From 703744fb8b76f70384cbc3341d5e1b3dbf30455e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 22 Jul 2026 11:47:53 +0200 Subject: [PATCH] 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) * fix(ai-sessions): only show open_preview card when the preview actually opened Co-Authored-By: Claude Opus 4.8 (1M context) * feat(ai-sessions): render preview card as a chip on the tool-call header row Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/ToolExecutionDisplay.svelte | 73 +++++++++++++------ .../copilot/chat/ToolMessageActions.svelte | 5 ++ .../copilot/chat/ToolPreviewCard.svelte | 45 ++++++++++++ .../components/copilot/chat/global/core.ts | 57 +++++++++++++-- .../components/copilot/chat/shared.test.ts | 21 ++++++ .../src/lib/components/copilot/chat/shared.ts | 33 ++++++++- .../(root)/(logged)/sessions/+page.svelte | 20 +++++ 7 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 59c9dcede4..be77af7e0a 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -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 + ) + ) {#if activeUserQuestion} @@ -57,32 +66,48 @@ {:else}
- + {/snippet} + + + {#if showPreviewChip && message.previewCard} +
+ {@render headerButton()} +
- + {:else} + {@render headerButton()} + {/if} {#if message.imageUrl} diff --git a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte index 58ebfe2bb8..02612492a5 100644 --- a/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolMessageActions.svelte @@ -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 diff --git a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte new file mode 100644 index 0000000000..79e37e90b7 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 3e8375bf39..d3ecb3330f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -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 +> = { + 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, diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 459ff6f5da..ec6febca80 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -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') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 55febeb014..ce2bfe0d02 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -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 & { diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a3f45f95fd..befe784637 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -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