mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
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:
co-authored by
Claude Opus 4.7
parent
2ab9d35c85
commit
9afc108c75
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user