feat: add resource preview cards and schedule modal under tool calls

Adds a Codex-style preview card under tool calls that create or modify a
flow step, schedule, script, or app, plus a clickable flow into a centered
modal hosting ScheduleEditorInner for the schedule case.

- ToolExecutionDisplay infers a PreviewKind + title/subtitle/icon from the
  tool name and parameters. Flow steps render the LanguageIcon for the
  step's language; schedule cards use a blue Calendar; script/app/flow
  fall back to lucide brand icons. The card itself is a button (only
  enabled for schedule for now); the inner Open span stops propagation so
  card-click and Open-click stay independent.
- New previewModalState.svelte.ts holds shared $state for the modal so
  ToolExecutionDisplay (deep in the message tree) can drive a modal
  mounted at the page level.
- design-sandbox/+page.svelte mounts a Modal2 (with target="body" so the
  Portal lands on <body> instead of collapsing inside the splitpanes
  flex tree) containing ScheduleEditorInner with useDrawer={false}. An
  $effect calls openEdit(SANDBOX_SCHEDULE_PATH) on the real workspace
  schedule when the modal opens, which avoids the undefined-field crashes
  that synthesizing a defaultCfg produced.
- Chat text bumped to text-xs across user bubbles, assistant markdown
  ([&_*]:!text-xs), and tool-call labels for visual consistency.
- Removed the trailing in-flight 'Updated Notify…' tool call from the
  fake conversation so the demo ends on the schedule preview card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-04-29 11:49:48 +02:00
co-authored by Claude Opus 4.7
parent 2ab9d35c85
commit 9afc108c75
6 changed files with 321 additions and 109 deletions
@@ -112,7 +112,7 @@
aiChatManager.disableAutomaticScroll()
}}
>
<div class="flex flex-col" bind:clientHeight={height}>
<div class="flex flex-col w-full max-w-xl mx-auto px-2" bind:clientHeight={height}>
{#each messages as message, messageIndex}
<AIChatMessage
{message}
@@ -71,7 +71,7 @@
{:else}
<div
class={twMerge(
'text-sm py-1 mx-2',
'text-xs py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
@@ -88,7 +88,7 @@
</div>
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-primary flex flex-row items-center justify-between gap-2 mt-2">
<div class="mx-2 text-xs text-primary flex flex-row items-center justify-between gap-2 mt-2">
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
<Button
size="xs2"
@@ -6,14 +6,14 @@
import LinkRenderer from './LinkRenderer.svelte'
interface Props {
message: DisplayMessage;
message: DisplayMessage
}
let { message }: Props = $props();
let { message }: Props = $props()
</script>
<div
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6"
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6 [&_*]:!text-xs"
>
<Markdown
md={message.content}
@@ -1,10 +1,23 @@
<script lang="ts">
import { Loader2, ChevronDown, ChevronRight, XCircle, Play } from 'lucide-svelte'
import {
Loader2,
ChevronDown,
ChevronRight,
XCircle,
Play,
ArrowUpRight,
Workflow,
Calendar,
FileCode,
LayoutDashboard
} from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { aiChatManager } from './AIChatManager.svelte'
import type { ToolDisplayMessage } from './shared'
import { twMerge } from 'tailwind-merge'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { openPreviewModal } from './previewModalState.svelte'
interface Props {
message: ToolDisplayMessage
@@ -21,107 +34,236 @@
(message.isStreamingArguments && hasParameters) ||
(message.isLoading && message.needsConfirmation)
)
// Codex-style "open the resource" preview card under tool calls that
// create or modify a flow step, schedule, script, or app. Inferred from
// the tool name; the parameters give us the resource title when available.
type PreviewKind = 'flow' | 'schedule' | 'script' | 'app'
type PreviewInfo = {
kind: PreviewKind
title: string
subtitle: string
icon: typeof Workflow
iconBg: string
iconColor: string
// When set, render LanguageIcon for that lang instead of the lucide icon.
language?: string
}
const previewInfo = $derived.by((): PreviewInfo | undefined => {
// Only show for create/edit/update tool calls that have completed successfully.
const name = (message.toolName ?? '').toLowerCase()
const verbMatch = /\b(create|add|edit|update|modify|set)/.test(name)
if (!verbMatch) return undefined
if (message.isLoading || message.error || message.needsConfirmation) return undefined
const params = (message.parameters ?? {}) as Record<string, any>
if (/(schedule|cron)/.test(name)) {
return {
kind: 'schedule',
title: params.summary ?? params.path ?? 'Schedule',
subtitle: 'Schedule',
icon: Calendar,
iconBg: 'bg-blue-50 dark:bg-blue-900/20',
iconColor: 'text-blue-500'
}
}
if (/(script)/.test(name)) {
return {
kind: 'script',
title: params.summary ?? params.path ?? 'Script',
subtitle: 'Script',
icon: FileCode,
iconBg: 'bg-violet-50 dark:bg-violet-900/20',
iconColor: 'text-violet-500'
}
}
if (/(app)/.test(name)) {
return {
kind: 'app',
title: params.summary ?? params.path ?? 'App',
subtitle: 'App',
icon: LayoutDashboard,
iconBg: 'bg-emerald-50 dark:bg-emerald-900/20',
iconColor: 'text-emerald-500'
}
}
if (/(flow|step|module)/.test(name)) {
return {
kind: 'flow',
title: params.summary ?? params.id ?? params.path ?? 'Flow step',
subtitle: /step|module/.test(name) ? 'Flow step' : 'Flow',
icon: Workflow,
iconBg: 'bg-sky-50 dark:bg-sky-900/20',
iconColor: 'text-sky-500',
language: typeof params.language === 'string' ? params.language : undefined
}
}
return undefined
})
</script>
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
<div class="flex flex-col gap-1.5">
<div
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden font-mono text-xs"
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
<!-- Collapsible Header -->
<button
class={twMerge(
'w-full p-2 bg-surface-secondary hover:bg-surface-hover transition-colors flex items-center justify-between text-left border-b border-gray-200 dark:border-gray-700',
message.needsConfirmation ? 'opacity-80' : ''
)}
onclick={() => (isExpanded = !isExpanded)}
disabled={!message.showDetails && !message.isStreamingArguments}
>
<div class="flex items-center gap-2 flex-1">
{#if message.showDetails || message.isStreamingArguments}
{#if isExpanded}
<ChevronDown class="w-3 h-3 text-secondary" />
{:else}
<ChevronRight class="w-3 h-3 text-secondary" />
{/if}
{/if}
{/if}
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500">✗</span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500">✓</span>
{/if}
<span class="text-primary font-medium text-2xs">
{message.content}
</span>
</div>
</button>
{#if message.isLoading && !message.needsConfirmation}
<Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500" />
{:else if message.error}
<span class="text-red-500">✗</span>
{:else if !message.isLoading && !message.error}
<span class="text-green-500">✓</span>
{/if}
<span class="text-primary font-medium text-xs">
{message.content}
</span>
</div>
</button>
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
content={message.parameters}
streaming={message.isStreamingArguments}
toolName={message.toolName}
showFade={message.showFade}
/>
</div>
{/if}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
<!-- Confirmation Footer -->
{#if message.needsConfirmation}
<div
class={twMerge(
'mt-3 pt-3 flex flex-row items-center justify-end gap-2',
hasParameters ? 'border-t border-gray-200 dark:border-gray-700' : ''
)}
>
Run
</Button>
</div>
<Button
variant="default"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, false)
}
}}
startIcon={{ icon: XCircle }}
destructive
></Button>
<Button
variant="accent"
size="xs"
on:click={() => {
if (message.tool_call_id) {
aiChatManager.handleToolConfirmation(message.tool_call_id, true)
}
}}
startIcon={{ icon: Play }}
>
Run
</Button>
</div>
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
<!-- Logs and Result - hide while streaming -->
{:else if !message.isStreamingArguments}
<ToolContentDisplay
title="Logs"
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
/>
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
</div>
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
loading={message.isLoading}
/>
{/if}
</div>
{/if}
</div>
{#if previewInfo}
{@const PreviewIcon = previewInfo.icon}
{@const clickable = previewInfo.kind === 'schedule'}
<button
type="button"
disabled={!clickable}
onclick={() => {
if (!clickable) return
openPreviewModal({
kind: previewInfo.kind,
title: previewInfo.title
})
}}
class={twMerge(
'flex items-center gap-3 p-2.5 bg-surface border border-gray-200 dark:border-gray-700 rounded-md font-sans w-full text-left',
clickable
? 'hover:bg-surface-hover hover:border-gray-300 dark:hover:border-gray-600 transition-colors cursor-pointer'
: 'cursor-default'
)}
>
<div
class={twMerge(
'w-8 h-8 rounded-md flex items-center justify-center shrink-0',
previewInfo.language ? 'bg-surface-secondary' : previewInfo.iconBg
)}
>
{#if previewInfo.language}
<LanguageIcon lang={previewInfo.language as any} width={18} height={18} />
{:else}
<PreviewIcon class={twMerge('w-4 h-4', previewInfo.iconColor)} />
{/if}
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs font-medium text-primary truncate">{previewInfo.title}</span>
<span class="text-2xs text-secondary truncate">{previewInfo.subtitle}</span>
</div>
<!-- The Open button intentionally stops propagation so the card click handler
(modal) and the Open click handler (full-page nav, TBD) stay independent. -->
<span
role="button"
tabindex="0"
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-gray-200 dark:border-gray-700 hover:bg-surface-hover transition-colors text-primary"
onclick={(e) => {
e.stopPropagation()
// TODO: navigate to the resource page
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
}
}}
>
<ArrowUpRight class="w-3.5 h-3.5" />
Open
</span>
</button>
{/if}
</div>
@@ -0,0 +1,35 @@
// Shared state for the "open resource" modal triggered by clicking a Codex-style
// preview card under a tool call (see ToolExecutionDisplay.svelte). The modal itself
// is mounted at the page level (e.g. /design-sandbox) so it overlays the whole UI.
export type PreviewModalKind = 'schedule' | 'flow' | 'script' | 'app'
type State = {
kind: PreviewModalKind | null
path: string
title: string
isFlow: boolean
}
export const previewModalState = $state<State>({
kind: null,
path: '',
title: '',
isFlow: false
})
export function openPreviewModal(opts: {
kind: PreviewModalKind
path?: string
title?: string
isFlow?: boolean
}) {
previewModalState.kind = opts.kind
previewModalState.path = opts.path ?? ''
previewModalState.title = opts.title ?? ''
previewModalState.isFlow = opts.isFlow ?? false
}
export function closePreviewModal() {
previewModalState.kind = null
}
@@ -8,6 +8,12 @@
import { copilotInfo } from '$lib/aiStore'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import {
previewModalState,
closePreviewModal
} from '$lib/components/copilot/chat/previewModalState.svelte'
// Fake conversation: user asks the assistant to build a 3-step user-engagement flow.
const fakeMessages: DisplayMessage[] = [
@@ -72,16 +78,6 @@
parameters: { cron: '0 9 * * 1-5', timezone: 'Europe/Paris' },
result: { ok: true, schedule_id: 'sched_001' },
showDetails: false
},
{
role: 'tool',
tool_call_id: 'tool_edit_c',
toolName: 'edit_step',
content: 'Updated `Notify` to reference `user.name`',
parameters: { id: 'c', diff: '- {{ user.first_name }}\n+ {{ user.name }}' },
result: { ok: true },
showDetails: false,
isLoading: true
}
]
@@ -126,6 +122,23 @@
setTimeout(applyMocks, 1500)
})
// Real schedule used by the schedule preview modal. The path must exist in the
// workspace — ScheduleEditorInner.openEdit fetches it via ScheduleService.getSchedule.
// Synthesizing a `defaultCfg` instead leaves too many fields undefined and crashes
// downstream sub-components (Path, ErrorOrRecoveryHandler, etc.).
const SANDBOX_SCHEDULE_PATH = 'u/guilhempw/fake_flow_schedule'
const SANDBOX_SCHEDULE_IS_FLOW = true
let scheduleEditor: ScheduleEditorInner | undefined = $state(undefined)
// When the schedule modal opens (kind = 'schedule'), load the real schedule so
// the editor populates fully (cron, timezone, runnable, handlers, etc.).
$effect(() => {
if (previewModalState.kind === 'schedule' && scheduleEditor) {
scheduleEditor.openEdit(SANDBOX_SCHEDULE_PATH, SANDBOX_SCHEDULE_IS_FLOW)
}
})
// Minimal fake flow with a few modules so the editor has something to show.
let flowStore = $state({
val: {
@@ -210,3 +223,25 @@
</Pane>
</Splitpanes>
</div>
<!-- Centered modal triggered by clicking a schedule preview card. Hosts
ScheduleEditorInner inline (no Drawer) so the user sees the same form
fields as on the schedule page. Two-way bind on isOpen syncs the modal's
internal close (X / esc / outside-click) back to the shared state. -->
<Modal2
title={`Edit schedule ${SANDBOX_SCHEDULE_PATH}`}
fixedWidth="lg"
fixedHeight="xl"
contentClasses="overflow-y-auto"
target="body"
bind:isOpen={
() => previewModalState.kind === 'schedule',
(v) => {
if (!v) closePreviewModal()
}
}
>
<div class="w-full">
<ScheduleEditorInner bind:this={scheduleEditor} useDrawer={false} />
</div>
</Modal2>