- {#if message.isLoading && !message.needsConfirmation}
-
- {/if}
-
- {message.content}
-
+ {#snippet headerButton()}
+
+ {/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