feat: Replaces WhatsApp support agent example with Telegram agent example (#35341)

This commit is contained in:
Joco-95 2026-08-03 15:25:08 +02:00 committed by GitHub
parent c6917a194f
commit e11a1e7d49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1529 additions and 348 deletions

View File

@ -1325,10 +1325,10 @@
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt": "When a new lead is created in my CRM, enrich it with Lemlist, score it based on fit, then update the lead if qualified and notify the sales team on Slack.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label": "Process invoices",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt": "Every morning, scan Gmail for new invoices, use Claude to extract details and cross-check them against purchase orders in Google Sheets, flag any discrepancies for review, and add all payment due dates to Google Calendar automatically.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label": "WhatsApp support agent",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt": "When a customer sends a WhatsApp message, use Gemini to match their question against our FAQ in Google Sheets and reply instantly. If it cannot resolve it, create a support ticket in Notion and alert the team on Slack.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label": "Schedule social posts",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt": "Every Monday, read this week's content ideas from a Google Sheet, use Gemini to write tailored content then schedule them to post throughout the week.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.telegramAgent.label": "Telegram support agent",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.telegramAgent.prompt": "Create a Telegram agent that answers customer questions using Claude, looks up order details in Gmail and our FAQ in Google Sheets, and escalates to a human when it is unsure.",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll": "See all",
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?",

View File

@ -84,3 +84,45 @@ describe('WorkflowPreviewCanvas renderScale', () => {
expect(style).toContain('scale(1)');
});
});
describe('WorkflowPreviewCanvas full-canvas visualization', () => {
const fullCanvasWorkflow: PreviewWorkflow = {
nodes: [],
connections: [],
fullCanvasVisualization: { type: 'agent-builder' },
};
function mountFullCanvas(animating: boolean) {
vi.mocked(useElementSize).mockReturnValue({
width: ref(1600),
height: ref(420),
} as unknown as ReturnType<typeof useElementSize>);
return mount(WorkflowPreviewCanvas, {
props: { workflow: fullCanvasWorkflow, animating },
global: { stubs: { WorkflowPreviewNode: true, AgentBuilderVisualization: true } },
});
}
it('renders the full-canvas visualization component', () => {
const wrapper = mountFullCanvas(false);
const viz = wrapper.findComponent({ name: 'AgentBuilderVisualization' });
expect(viz.exists()).toBe(true);
expect(viz.props('active')).toBe(false);
});
it('activates the visualization when animating', async () => {
const wrapper = mountFullCanvas(true);
await wrapper.vm.$nextTick();
const viz = wrapper.findComponent({ name: 'AgentBuilderVisualization' });
expect(viz.props('active')).toBe(true);
});
it('stays active after the visualization completes', async () => {
const wrapper = mountFullCanvas(true);
const viz = wrapper.findComponent({ name: 'AgentBuilderVisualization' });
viz.vm.$emit('complete');
await wrapper.vm.$nextTick();
expect(wrapper.findComponent({ name: 'AgentBuilderVisualization' }).props('active')).toBe(true);
});
});

View File

@ -11,7 +11,7 @@ import WorkflowPreviewNode from './WorkflowPreviewNode.vue';
import SlackMessageVisualization from './visualizations/SlackMessageVisualization.vue';
import SalesforceCardVisualization from './visualizations/SalesforceCardVisualization.vue';
import InvoiceSpreadsheetVisualization from './visualizations/InvoiceSpreadsheetVisualization.vue';
import WhatsAppChatVisualization from './visualizations/WhatsAppChatVisualization.vue';
import AgentBuilderVisualization from './visualizations/AgentBuilderVisualization.vue';
const NODE_HALF_WIDTH = 48;
const EDGE_CURVE_OFFSET = 60;
@ -28,7 +28,7 @@ const visualizationComponents: Record<PreviewVisualizationType, Component> = {
'slack-message': SlackMessageVisualization,
'salesforce-card': SalesforceCardVisualization,
'invoice-spreadsheet': InvoiceSpreadsheetVisualization,
'whatsapp-chat': WhatsAppChatVisualization,
'agent-builder': AgentBuilderVisualization,
};
const props = withDefaults(
@ -40,13 +40,20 @@ const props = withDefaults(
);
const canvasRef = ref<HTMLElement | null>(null);
const { width: canvasRenderedWidth } = useElementSize(canvasRef);
const { width: canvasRenderedWidth, height: canvasRenderedHeight } = useElementSize(canvasRef);
const animationPhase = ref<AnimationPhase>('idle');
const nodeStates = reactive<Record<string, NodeAnimationState>>({});
const hasInputViz = computed(() => !!props.workflow.inputVisualization);
const hasOutputViz = computed(() => !!props.workflow.outputVisualization);
const hasFullCanvasViz = computed(() => !!props.workflow.fullCanvasVisualization);
const fullCanvasVizComponent = computed(() =>
props.workflow.fullCanvasVisualization
? visualizationComponents[props.workflow.fullCanvasVisualization.type]
: undefined,
);
const outputVizItems = computed((): PreviewOutputVisualization[] => {
const viz = props.workflow.outputVisualization;
@ -147,7 +154,7 @@ async function runNodeAnimation() {
function startAnimation() {
stopped = false;
resetStates();
if (hasInputViz.value) {
if (hasFullCanvasViz.value || hasInputViz.value) {
animationPhase.value = 'input';
} else {
animationPhase.value = 'nodes';
@ -169,6 +176,11 @@ function handleInputComplete() {
void runNodeAnimation();
}
function handleFullCanvasComplete() {
if (stopped) return;
animationPhase.value = 'done';
}
function handleOutputComplete() {
if (stopped) return;
outputCompletedCount++;
@ -291,7 +303,9 @@ const renderScale = computed(() =>
const contentStyle = computed(() => {
const rs = renderScale.value;
const verticalOffset = (CANVAS_HEIGHT * (1 - rs)) / 2;
const renderedHeight =
canvasRenderedHeight.value > 0 ? canvasRenderedHeight.value : CANVAS_HEIGHT;
const verticalOffset = Math.max(0, (renderedHeight - CANVAS_HEIGHT * rs) / 2);
return {
width: `${CANVAS_WIDTH}px`,
height: `${CANVAS_HEIGHT}px`,
@ -436,6 +450,15 @@ function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
</div>
</div>
<div v-if="fullCanvasVizComponent" :class="$style.fullCanvasSlot">
<component
:is="fullCanvasVizComponent"
:active="animationPhase !== 'idle'"
v-bind="props.workflow.fullCanvasVisualization?.props"
@complete="handleFullCanvasComplete"
/>
</div>
<div v-if="inputVizComponent" :class="$style.vizSlot" :style="inputSlotStyle">
<component
:is="inputVizComponent"
@ -472,7 +495,7 @@ function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
position: relative;
width: 100%;
max-width: 1600px;
height: 420px;
height: var(--workflow-preview-canvas-height, 420px);
margin: 0 auto;
overflow: hidden;
background-color: var(--canvas--color--background);
@ -529,6 +552,15 @@ function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
pointer-events: none;
}
.fullCanvasSlot {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.vizSlotCompact {
transform: translateY(-50%);
transform-origin: left center;

View File

@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import AgentBuilderVisualization from './AgentBuilderVisualization.vue';
function setPrefersReducedMotion(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn(() => ({ matches })),
});
}
describe('AgentBuilderVisualization', () => {
beforeEach(() => {
vi.useFakeTimers();
setPrefersReducedMotion(false);
});
afterEach(() => {
vi.useRealTimers();
});
it('emits complete after the animation sequence finishes', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
await vi.advanceTimersByTimeAsync(20000);
expect(wrapper.emitted('complete')).toHaveLength(1);
});
it('fills in the builder sections as the animation progresses', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
expect(wrapper.text()).not.toContain('Gmail');
await vi.advanceTimersByTimeAsync(20000);
const text = wrapper.text();
expect(text).toContain('Gmail');
expect(text).toContain('Google Sheets');
expect(text).toContain('Summarize conversations');
// The agent is not scheduled — the Schedules section stays empty.
expect(text).toContain('Add schedule');
expect(text).not.toContain('Daily digest');
expect(text).toContain('Claude Opus 5');
expect(text).toContain('escalate to a human');
});
it('stops and resets when deactivated', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
await vi.advanceTimersByTimeAsync(2000);
await wrapper.setProps({ active: false });
await vi.advanceTimersByTimeAsync(20000);
expect(wrapper.emitted('complete')).toBeUndefined();
expect(wrapper.text()).not.toContain('Gmail');
});
it('does not emit complete when never activated', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: false },
});
await vi.advanceTimersByTimeAsync(20000);
expect(wrapper.emitted('complete')).toBeUndefined();
});
it('renders the publish button and flips it to Published after typing finishes', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
await vi.advanceTimersByTimeAsync(2000);
const button = wrapper.find('[data-testid="publish-agent-button"]');
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Publish');
await vi.advanceTimersByTimeAsync(18000);
expect(wrapper.find('[data-testid="publish-agent-button"]').text()).toBe('Published');
});
it('plays the Telegram chat first, then the builder, and emits complete at the end', async () => {
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
// Chat phase: conversation is playing, builder sections untouched.
await vi.advanceTimersByTimeAsync(2000);
expect(wrapper.text()).toContain('order #4712');
expect(wrapper.text()).not.toContain('Claude Opus 5');
expect(wrapper.emitted('complete')).toBeUndefined();
// Builder phase: sections are being filled in.
await vi.advanceTimersByTimeAsync(14000);
expect(wrapper.text()).toContain('Claude Opus 5');
expect(wrapper.emitted('complete')).toBeUndefined();
await vi.advanceTimersByTimeAsync(6000);
expect(wrapper.text()).toContain('Published');
expect(wrapper.emitted('complete')).toHaveLength(1);
});
it('shows the final state and completes promptly with reduced motion', async () => {
setPrefersReducedMotion(true);
const wrapper = mount(AgentBuilderVisualization, {
props: { active: true },
});
await vi.advanceTimersByTimeAsync(3000);
const text = wrapper.text();
expect(text).toContain('Claude Opus 5');
expect(text).toContain('Published');
// The chat phase is skipped in the reduced-motion end state.
expect(text).not.toContain('order #4712');
expect(wrapper.emitted('complete')).toHaveLength(1);
});
});

View File

@ -0,0 +1,875 @@
<script lang="ts" setup>
import { computed, ref, watch, onUnmounted } from 'vue';
import { N8nButton, N8nIcon, N8nIconButton } from '@n8n/design-system';
import {
GMAIL_ICON_SVG,
ANTHROPIC_ICON_SVG,
GOOGLE_SHEETS_ICON_SVG,
} from '../../workflows/process-invoices';
import TelegramChatVisualization from './TelegramChatVisualization.vue';
const INSTRUCTIONS_TEXT =
'You are a friendly support agent. Answer customer questions on Telegram, look up order details in Gmail and the FAQ sheet, and escalate to a human when unsure.';
const PANEL_APPEAR_MS = 150;
const CHANNEL_ADDED_MS = 900;
const TOOL_1_MS = 1550;
const TOOL_2_MS = 1900;
const SKILL_MS = 2350;
const SCROLL_1_MS = 2900;
const MODEL_MS = 3800;
const SCROLL_2_MS = 4450;
const TYPING_START_MS = 5000;
const TYPING_INTERVAL_MS = 14;
const CURSOR_APPEAR_MS = 200;
const CURSOR_MOVE_START_MS = 350;
const CURSOR_MOVE_DURATION_MS = 450;
const CLICK_MS = CURSOR_MOVE_START_MS + CURSOR_MOVE_DURATION_MS + 50;
const PUBLISH_PRESS_DURATION_MS = 180;
const CURSOR_FADE_MS = CLICK_MS + PUBLISH_PRESS_DURATION_MS + 320;
const COMPLETE_AFTER_PUBLISH_MS = CURSOR_FADE_MS + 600;
const REDUCED_MOTION_COMPLETE_HOLD_MS = 800;
const SPARKLE_DURATION_MS = 700;
const SPARKLE_DOTS = [
{ x: '-26px', y: '-14px', delay: '0ms' },
{ x: '-18px', y: '14px', delay: '60ms' },
{ x: '-2px', y: '-20px', delay: '30ms' },
{ x: '8px', y: '18px', delay: '80ms' },
{ x: '22px', y: '-12px', delay: '20ms' },
{ x: '28px', y: '8px', delay: '70ms' },
];
const props = defineProps<{
active: boolean;
}>();
const emit = defineEmits<{ complete: [] }>();
const panelVisible = ref(false);
const channelAdded = ref(false);
const toolsShown = ref(0);
const skillAdded = ref(false);
const modelSelected = ref(false);
const typedText = ref('');
const typingDone = ref(false);
const scrollY = ref(0);
const publishPressed = ref(false);
const published = ref(false);
const sparklesVisible = ref(false);
const chatActive = ref(false);
const builderStarted = ref(false);
const cursorVisible = ref(false);
const cursorAtButton = ref(false);
const cursorClicking = ref(false);
const viewportRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
const modelSectionRef = ref<HTMLElement | null>(null);
const instructionsSectionRef = ref<HTMLElement | null>(null);
const remainingChannels = computed(() =>
channelAdded.value ? 'Slack, Linear' : 'Slack, Telegram, Linear',
);
let timers: Array<ReturnType<typeof setTimeout>> = [];
let typingInterval: ReturnType<typeof setInterval> | null = null;
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
if (typingInterval) {
clearInterval(typingInterval);
typingInterval = null;
}
}
function schedule(ms: number, fn: () => void) {
timers.push(setTimeout(fn, ms));
}
function scrollToSection(el: HTMLElement | null, offset = 14) {
if (!el || !contentRef.value || !viewportRef.value) return;
const maxScroll = Math.max(0, contentRef.value.scrollHeight - viewportRef.value.clientHeight);
scrollY.value = Math.min(Math.max(0, el.offsetTop - offset), maxScroll);
}
function startTyping(onDone: () => void) {
let index = 0;
typingInterval = setInterval(() => {
index++;
typedText.value = INSTRUCTIONS_TEXT.slice(0, index);
if (index >= INSTRUCTIONS_TEXT.length) {
if (typingInterval) clearInterval(typingInterval);
typingInterval = null;
typingDone.value = true;
onDone();
}
}, TYPING_INTERVAL_MS);
}
function resetState() {
panelVisible.value = false;
channelAdded.value = false;
toolsShown.value = 0;
skillAdded.value = false;
modelSelected.value = false;
typedText.value = '';
typingDone.value = false;
scrollY.value = 0;
publishPressed.value = false;
published.value = false;
sparklesVisible.value = false;
chatActive.value = false;
builderStarted.value = false;
cursorVisible.value = false;
cursorAtButton.value = false;
cursorClicking.value = false;
}
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function showFinalState() {
builderStarted.value = true;
panelVisible.value = true;
channelAdded.value = true;
toolsShown.value = 2;
skillAdded.value = true;
modelSelected.value = true;
typedText.value = INSTRUCTIONS_TEXT;
typingDone.value = true;
scrollToSection(instructionsSectionRef.value, 40);
published.value = true;
}
function runPublishSequence() {
schedule(CURSOR_APPEAR_MS, () => (cursorVisible.value = true));
schedule(CURSOR_MOVE_START_MS, () => (cursorAtButton.value = true));
schedule(CLICK_MS, () => {
cursorClicking.value = true;
publishPressed.value = true;
});
schedule(CLICK_MS + PUBLISH_PRESS_DURATION_MS, () => {
cursorClicking.value = false;
publishPressed.value = false;
published.value = true;
sparklesVisible.value = true;
});
schedule(CLICK_MS + PUBLISH_PRESS_DURATION_MS + SPARKLE_DURATION_MS, () => {
sparklesVisible.value = false;
});
schedule(CURSOR_FADE_MS, () => (cursorVisible.value = false));
schedule(COMPLETE_AFTER_PUBLISH_MS, () => emit('complete'));
}
function runBuilderAnimation() {
schedule(PANEL_APPEAR_MS, () => (panelVisible.value = true));
schedule(CHANNEL_ADDED_MS, () => (channelAdded.value = true));
schedule(TOOL_1_MS, () => (toolsShown.value = 1));
schedule(TOOL_2_MS, () => (toolsShown.value = 2));
schedule(SKILL_MS, () => (skillAdded.value = true));
schedule(SCROLL_1_MS, () => scrollToSection(modelSectionRef.value, 150));
schedule(MODEL_MS, () => (modelSelected.value = true));
schedule(SCROLL_2_MS, () => scrollToSection(instructionsSectionRef.value, 40));
schedule(TYPING_START_MS, () => {
startTyping(runPublishSequence);
});
}
function handleChatComplete() {
builderStarted.value = true;
runBuilderAnimation();
}
function runAnimation() {
resetState();
if (prefersReducedMotion()) {
schedule(PANEL_APPEAR_MS, showFinalState);
schedule(PANEL_APPEAR_MS + REDUCED_MOTION_COMPLETE_HOLD_MS, () => emit('complete'));
return;
}
chatActive.value = true;
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
resetState();
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="$style.stage">
<div :class="[$style.panel, panelVisible && $style.panelVisible]">
<div
aria-hidden="true"
:class="[$style.publishGroup, publishPressed && $style.publishPressed]"
>
<N8nButton
:class="$style.groupButtonLeft"
:disabled="published"
variant="ghost"
data-testid="publish-agent-button"
tabindex="-1"
>
<div :class="$style.publishFlex">
<span v-if="published" :class="[$style.indicatorDot, $style.indicatorPublished]" />
<span :class="{ [$style.indicatorPublishedText]: published }">
{{ published ? 'Published' : 'Publish' }}
</span>
</div>
</N8nButton>
<N8nIconButton
:class="$style.groupButtonRight"
variant="ghost"
icon="chevron-down"
aria-label="Publish options"
tabindex="-1"
/>
<span v-if="sparklesVisible" :class="$style.sparkles">
<span
v-for="(dot, index) in SPARKLE_DOTS"
:key="index"
:class="$style.sparkleDot"
:style="{ '--sx': dot.x, '--sy': dot.y, animationDelay: dot.delay }"
/>
</span>
</div>
<svg
aria-hidden="true"
:class="[
$style.cursor,
cursorVisible && $style.cursorVisible,
cursorAtButton && $style.cursorAtButton,
cursorClicking && $style.cursorClicking,
]"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5.5 2.6v16.2l3.8-3.7 2.5 5.7 2.4-1-2.5-5.7h5.4L5.5 2.6Z"
fill="#ffffff"
stroke="#000000"
stroke-width="1.3"
stroke-linejoin="round"
/>
</svg>
<div ref="viewportRef" :class="$style.panelViewport">
<div
ref="contentRef"
:class="$style.content"
:style="{ transform: `translateY(-${scrollY}px)` }"
>
<div :class="$style.sections">
<div :class="$style.channelsSection">
<div :class="$style.sectionLabel">Channels</div>
<div :class="$style.channelGrid">
<div v-if="channelAdded" :class="[$style.channelCard, $style.popIn]">
<N8nIcon icon="telegram" :size="20" :class="$style.channelIcon" />
<div :class="$style.channelCardText">
<span :class="$style.channelCardTitle">Telegram</span>
<span :class="$style.channelCardSubtitle">Telegram account</span>
</div>
</div>
<div :class="$style.channelCard">
<N8nIcon icon="plus" :size="24" :class="$style.channelPlusIcon" />
<div :class="$style.channelCardText">
<span :class="$style.channelCardTitle">Add channel</span>
<span :class="$style.channelCardSubtitle">{{ remainingChannels }}</span>
</div>
</div>
</div>
</div>
<div :class="$style.capabilityRow">
<div :class="$style.rowLabel">Tools</div>
<div :class="$style.chips">
<template v-if="toolsShown > 0">
<div :class="[$style.chip, $style.popIn]">
<img :src="GMAIL_ICON_SVG" :class="$style.chipBrandIcon" alt="" />
<span :class="$style.chipText">Gmail</span>
</div>
<div v-if="toolsShown > 1" :class="[$style.chip, $style.popIn]">
<img :src="GOOGLE_SHEETS_ICON_SVG" :class="$style.chipBrandIcon" alt="" />
<span :class="$style.chipText">Google Sheets</span>
</div>
<div :class="$style.plusButton">
<N8nIcon icon="plus" :size="16" />
</div>
</template>
<div v-else :class="$style.emptyCta">Add tool</div>
</div>
</div>
<div :class="$style.capabilityRow">
<div :class="$style.rowLabel">Skills</div>
<div :class="$style.chips">
<template v-if="skillAdded">
<div :class="[$style.chip, $style.popIn]">
<N8nIcon icon="sparkles" :size="16" :class="$style.chipIcon" />
<span :class="$style.chipText">Summarize conversations</span>
</div>
<div :class="$style.plusButton">
<N8nIcon icon="plus" :size="16" />
</div>
</template>
<div v-else :class="$style.emptyCta">Add skill</div>
</div>
</div>
<div :class="$style.capabilityRow">
<div :class="$style.emptyCta">Add sub-agent</div>
</div>
<div :class="$style.capabilityRow">
<div :class="$style.rowLabel">Schedules</div>
<div :class="$style.chips">
<div :class="$style.emptyCta">Add schedule</div>
</div>
</div>
<div ref="modelSectionRef" :class="$style.fieldSection">
<div :class="$style.sectionLabel">Model</div>
<div :class="$style.modelTrigger">
<div :class="$style.modelSelected">
<template v-if="modelSelected">
<img
:src="ANTHROPIC_ICON_SVG"
:class="[$style.modelBrandIcon, $style.popIn]"
alt=""
/>
<span :class="[$style.modelName, $style.popIn]">Claude Opus 5</span>
<span :class="[$style.modelCredential, $style.popIn]">Anthropic account</span>
</template>
<template v-else>
<N8nIcon icon="bot" :size="16" :class="$style.modelPlaceholderIcon" />
<span :class="$style.modelPlaceholder">Select model</span>
</template>
</div>
<N8nIcon icon="chevron-down" :size="16" :class="$style.modelChevron" />
</div>
</div>
<div ref="instructionsSectionRef" :class="$style.fieldSection">
<div :class="$style.sectionLabel">Instructions</div>
<div :class="$style.editor">
<div :class="$style.editorToolbar">
<div :class="$style.toolbarGroup">
<N8nIcon icon="type" :size="14" />
<N8nIcon icon="chevron-down" :size="12" />
</div>
<div :class="$style.toolbarDivider" />
<N8nIcon icon="bold" :size="14" />
<N8nIcon icon="italic" :size="14" />
<N8nIcon icon="strikethrough" :size="14" />
<div :class="$style.toolbarDivider" />
<N8nIcon icon="list" :size="14" />
<N8nIcon icon="list-checks" :size="14" />
<N8nIcon icon="file-code" :size="14" />
<N8nIcon icon="quote" :size="14" />
<div :class="$style.toolbarDivider" />
<N8nIcon icon="undo-2" :size="14" />
<N8nIcon icon="redo-2" :size="14" />
</div>
<div :class="$style.editorContent">
<span>{{ typedText }}</span>
<span v-if="!typingDone" :class="$style.caret" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div :class="[$style.chatSlot, builderStarted && $style.chatHidden]">
<TelegramChatVisualization :active="chatActive" @complete="handleChatComplete" />
</div>
</div>
</template>
<style lang="scss" module>
.stage {
display: grid;
place-items: center;
> * {
grid-area: 1 / 1;
}
}
.panel {
position: relative;
width: 660px;
height: 384px;
background: light-dark(var(--background--surface), var(--background--subtle));
border: var(--border);
border-radius: var(--radius--lg);
box-shadow: var(--shadow--md, var(--shadow--sm));
overflow: hidden;
opacity: 0;
transform: translateY(10px) scale(0.98);
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.panelVisible {
opacity: 1;
transform: translateY(0) scale(1);
}
.publishGroup {
position: absolute;
top: var(--spacing--sm);
right: var(--spacing--sm);
z-index: 1;
display: inline-flex;
border: var(--border);
border-radius: var(--radius--3xs);
background: light-dark(var(--background--surface), var(--background--subtle));
transition: transform 0.15s ease;
}
.publishPressed {
transform: scale(0.94);
}
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
}
.sparkleDot {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--background--brand) 68%, var(--text-color));
box-shadow: 0 0 4px color-mix(in srgb, var(--background--brand) 60%, transparent);
opacity: 0;
animation: agent-builder-sparkle 0.55s ease-out both;
}
@keyframes agent-builder-sparkle {
0% {
opacity: 0;
transform: translate(0, 0) scale(0.4);
}
25% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translate(var(--sx), var(--sy)) scale(1);
}
}
.cursor {
position: absolute;
top: 0;
left: 0;
z-index: 2;
opacity: 0;
transform: translate(555px, 95px);
transform-origin: 4px 2px;
transition:
transform 0.45s cubic-bezier(0.3, 0.7, 0.25, 1),
opacity 0.25s ease;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
pointer-events: none;
}
.cursorVisible {
opacity: 1;
}
.cursorAtButton {
transform: translate(583px, 26px);
}
.cursorAtButton.cursorClicking {
transform: translate(583px, 26px) scale(0.82);
transition:
transform 0.12s ease,
opacity 0.25s ease;
}
.groupButtonLeft,
.groupButtonLeft:disabled,
.groupButtonLeft:hover:disabled {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right-color: transparent;
}
.groupButtonRight {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: var(--border);
}
.publishFlex {
display: flex;
align-items: center;
}
.indicatorDot {
display: inline-block;
width: var(--spacing--2xs);
height: var(--spacing--2xs);
margin-right: var(--spacing--2xs);
border-radius: 50%;
}
.indicatorPublished {
background-color: var(--color--mint-600);
}
.indicatorPublishedText {
color: var(--color--text--tint-1);
}
.chatSlot {
pointer-events: none;
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.chatHidden {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
.panelViewport {
position: relative;
height: 100%;
overflow: hidden;
mask-image: linear-gradient(
to bottom,
transparent 0,
black 14px,
black calc(100% - 14px),
transparent 100%
);
}
.content {
position: relative;
display: flex;
flex-direction: column;
padding: var(--spacing--lg) var(--spacing--xl);
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.sections {
display: flex;
flex-direction: column;
gap: var(--spacing--md);
}
.sectionLabel {
line-height: var(--height--lg);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
color: var(--text-color, var(--color--text--base));
}
.channelsSection {
display: flex;
flex-direction: column;
}
.channelGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing--xs);
}
.channelCard {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing--xs);
border-radius: var(--radius--xs);
gap: var(--spacing--xs);
background-color: var(--background--surface);
border: var(--border);
box-shadow: var(--shadow--xs);
}
.channelIcon {
color: var(--text-color, var(--color--text--base));
}
.channelPlusIcon {
color: var(--text-color--subtler, var(--color--text--light));
}
.channelCardText {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--3xs);
min-width: 0;
white-space: nowrap;
}
.channelCardTitle {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--text-color, var(--color--text--base));
}
.channelCardSubtitle {
font-size: var(--font-size--xs);
color: var(--text-color--subtler, var(--color--text--light));
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.capabilityRow {
display: flex;
align-items: flex-start;
gap: var(--spacing--2xs);
min-height: var(--height--md);
}
.rowLabel {
flex: 0 0 max(11%, calc(var(--spacing--3xl) + var(--spacing--sm)));
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
line-height: var(--height--md);
color: var(--text-color, var(--color--text--base));
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing--3xs);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing--2xs);
height: var(--height--md);
padding: var(--spacing--xs);
border: var(--border);
border-radius: var(--radius--full);
background: light-dark(var(--background--surface), var(--background--subtle));
box-shadow: var(--shadow--xs);
}
.chipIcon {
color: var(--text-color--subtler, var(--color--text--light));
flex-shrink: 0;
}
.chipBrandIcon {
width: 16px;
height: 16px;
object-fit: contain;
flex-shrink: 0;
transform: scale(1.3);
transform-origin: center;
}
.chipText {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
white-space: nowrap;
color: var(--text-color, var(--color--text--base));
padding-right: var(--spacing--4xs);
}
.plusButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--height--md);
height: var(--height--md);
color: var(--text-color--subtler, var(--color--text--light));
}
.emptyCta {
font-size: var(--font-size--sm);
line-height: var(--height--md);
color: var(--text-color--subtler, var(--color--text--light));
}
.fieldSection {
display: flex;
flex-direction: column;
}
.modelTrigger {
display: flex;
align-items: center;
height: var(--height--lg);
padding: 0 var(--spacing--xs);
gap: var(--spacing--xs);
border: var(--border);
background-color: var(--background--surface);
border-radius: var(--radius--2xs);
font-size: var(--font-size--sm);
}
.modelSelected {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
gap: var(--spacing--2xs);
}
.modelBrandIcon {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.modelName {
font-weight: var(--font-weight--bold);
color: var(--text-color, var(--color--text--base));
}
.modelCredential {
font-weight: var(--font-weight--bold);
color: var(--text-color--subtler, var(--color--text--light));
}
.modelPlaceholderIcon {
color: var(--text-color--subtler, var(--color--text--light));
}
.modelPlaceholder {
color: var(--text-color, var(--color--text--base));
}
.modelChevron {
color: var(--text-color--subtler, var(--color--text--light));
flex-shrink: 0;
}
.editor {
border: var(--border);
border-radius: var(--radius--2xs);
background-color: var(--background--surface);
overflow: hidden;
}
.editorToolbar {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--2xs) var(--spacing--xs);
border-bottom: var(--border);
color: var(--text-color--subtler, var(--color--text--light));
}
.toolbarGroup {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.toolbarDivider {
width: 1px;
height: 14px;
background-color: var(--border-color);
}
.editorContent {
min-height: 96px;
padding: var(--spacing--xs);
font-size: var(--font-size--sm);
line-height: var(--line-height--md);
color: var(--text-color, var(--color--text--base));
}
.caret {
display: inline-block;
width: 1.5px;
height: 1em;
margin-left: 1px;
vertical-align: text-bottom;
background-color: var(--text-color, var(--color--text--base));
animation: agent-builder-caret-blink 1s step-end infinite;
}
.popIn {
animation: agent-builder-pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes agent-builder-pop-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes agent-builder-caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.panel,
.content,
.publishGroup,
.cursor,
.chatSlot {
transition: none;
}
.popIn {
animation: none;
}
.sparkleDot {
animation: none;
}
}
</style>

View File

@ -0,0 +1,412 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
import { N8nIcon } from '@n8n/design-system';
interface ChatMessage {
from: 'user' | 'agent';
text: string;
time: string;
}
const MESSAGES: ChatMessage[] = [
{ from: 'user', text: 'Hi! Any update on my order #4712?', time: '9:41' },
{
from: 'agent',
text: 'I found your receipt in Gmail - order #4712 shipped yesterday and arrives Thursday.',
time: '9:41',
},
{ from: 'user', text: 'Can I still change the delivery address?', time: '9:42' },
{
from: 'agent',
text: "Yes, until it leaves the warehouse. I've looped in a teammate to confirm the change.",
time: '9:42',
},
];
const WINDOW_APPEAR_MS = 250;
const FIRST_MESSAGE_MS = 700;
const MESSAGE_GAP_MS = 1000;
const AGENT_TYPING_MS = 1200;
const COMPLETE_HOLD_MS = 1400;
const props = defineProps<{
active: boolean;
}>();
const emit = defineEmits<{ complete: [] }>();
const visible = ref(false);
const shownCount = ref(0);
const agentTyping = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function schedule(ms: number, fn: () => void) {
timers.push(setTimeout(fn, ms));
}
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function resetState() {
visible.value = false;
shownCount.value = 0;
agentTyping.value = false;
}
function runAnimation() {
resetState();
if (prefersReducedMotion()) {
schedule(WINDOW_APPEAR_MS, () => {
visible.value = true;
shownCount.value = MESSAGES.length;
});
schedule(WINDOW_APPEAR_MS + COMPLETE_HOLD_MS, () => emit('complete'));
return;
}
schedule(WINDOW_APPEAR_MS, () => (visible.value = true));
let at = WINDOW_APPEAR_MS + FIRST_MESSAGE_MS;
MESSAGES.forEach((message, index) => {
if (message.from === 'agent') {
schedule(at, () => (agentTyping.value = true));
at += AGENT_TYPING_MS;
}
schedule(at, () => {
agentTyping.value = false;
shownCount.value = index + 1;
});
at += MESSAGE_GAP_MS;
});
schedule(at - MESSAGE_GAP_MS + COMPLETE_HOLD_MS, () => emit('complete'));
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
resetState();
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="[$style.window, visible && $style.windowVisible]">
<div :class="$style.header">
<div :class="$style.avatar">
<N8nIcon icon="telegram" :size="42" />
</div>
<div :class="$style.headerText">
<span :class="$style.headerName">Support Agent</span>
<span :class="$style.headerStatus">bot</span>
</div>
</div>
<div :class="$style.messages">
<template v-for="(message, index) in MESSAGES" :key="index">
<div
v-if="index < shownCount"
:class="[$style.bubbleRow, message.from === 'user' && $style.bubbleRowOutgoing]"
>
<div
:class="[
$style.bubble,
message.from === 'user' ? $style.bubbleOutgoing : $style.bubbleIncoming,
]"
>
<p :class="$style.bubbleText">{{ message.text }}</p>
<span :class="$style.bubbleMeta">
{{ message.time }}
<svg
v-if="message.from === 'user'"
:class="$style.readTicks"
width="15"
height="10"
viewBox="0 0 18 12"
fill="none"
>
<path
d="M1 6 L4.5 9.5 L10 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6.5 6 L10 9.5 L15.5 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</div>
</div>
</template>
<div v-if="agentTyping" :class="$style.bubbleRow">
<div :class="[$style.bubble, $style.bubbleIncoming, $style.typingBubble]">
<span :class="$style.typingDot" />
<span :class="$style.typingDot" />
<span :class="$style.typingDot" />
</div>
</div>
</div>
<div :class="$style.inputBar">
<div :class="$style.roundButton">
<N8nIcon icon="paperclip" :size="18" />
</div>
<div :class="$style.messageInput">Message</div>
<div :class="$style.roundButton">
<N8nIcon icon="mic" :size="18" />
</div>
</div>
</div>
</template>
<style lang="scss" module>
.window {
display: flex;
flex-direction: column;
width: 460px;
height: 400px;
border: var(--border);
border-radius: var(--radius--lg);
box-shadow: var(--shadow--md, var(--shadow--sm));
overflow: hidden;
background: light-dark(#e7ebf0, #0e1621);
opacity: 0;
transform: translateY(10px) scale(0.98);
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
.windowVisible {
opacity: 1;
transform: translateY(0) scale(1);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--md);
background: light-dark(#fff, #17212b);
border-bottom: 1px solid light-dark(#e3e6e8, #101921);
flex-shrink: 0;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: #fff;
overflow: hidden;
flex-shrink: 0;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.headerName {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: light-dark(#000, #f5f5f5);
line-height: 1.25;
}
.headerStatus {
font-size: var(--font-size--xs);
color: light-dark(#787f85, #708499);
line-height: 1.25;
}
.messages {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--spacing--2xs);
flex: 1;
min-height: 0;
padding: var(--spacing--sm);
overflow: hidden;
}
.bubbleRow {
display: flex;
justify-content: flex-start;
}
.bubbleRowOutgoing {
justify-content: flex-end;
}
.bubble {
max-width: 78%;
padding: 6px 10px;
border-radius: 12px;
animation: telegram-bubble-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.bubbleIncoming {
background: light-dark(#fff, #182533);
border-bottom-left-radius: 4px;
}
.bubbleOutgoing {
background: light-dark(#effdde, #2b5278);
border-bottom-right-radius: 4px;
}
.bubbleText {
margin: 0;
font-size: var(--font-size--sm);
line-height: 1.45;
color: light-dark(#000, #f5f5f5);
}
.bubbleMeta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
margin-top: 1px;
font-size: 10px;
color: light-dark(#a0acb6, #708499);
}
.bubbleOutgoing .bubbleMeta {
color: light-dark(#4fae4e, #71baf5);
}
.readTicks {
flex-shrink: 0;
}
.typingBubble {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 28px;
}
.typingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: light-dark(#a0acb6, #708499);
animation: telegram-typing-bounce 1.2s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.15s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
}
.inputBar {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--2xs) var(--spacing--xs);
background: light-dark(#fff, #17212b);
border-top: 1px solid light-dark(#e3e6e8, #101921);
flex-shrink: 0;
}
.roundButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: light-dark(#f1f4f7, #242f3d);
color: light-dark(#787f85, #708499);
flex-shrink: 0;
}
.messageInput {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 36px;
padding: 0 var(--spacing--sm);
border-radius: var(--radius--full);
background: light-dark(#f1f4f7, #242f3d);
font-size: var(--font-size--sm);
color: light-dark(#a0acb6, #708499);
}
@keyframes telegram-bubble-pop {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes telegram-typing-bounce {
0%,
60%,
100% {
opacity: 0.4;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
@media (prefers-reduced-motion: reduce) {
.window {
transition: none;
}
.bubble {
animation: none;
}
.typingDot {
animation: none;
}
}
</style>

View File

@ -1,230 +0,0 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const BUBBLE_DELAY_MS = 350;
const COMPLETE_DELAY_MS = BUBBLE_DELAY_MS + 700;
const WHATSAPP_ICON =
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2260%22%20height%3D%2260%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22m4.868%2043.303%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311z%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M4.868%2043.803a.5.5%200%200%201-.482-.631l2.639-9.636a19.5%2019.5%200%200%201-2.497-9.556C4.532%2013.238%2013.273%204.5%2024.014%204.5a19.37%2019.37%200%200%201%2013.784%205.713A19.36%2019.36%200%200%201%2043.5%2023.994c-.004%2010.741-8.746%2019.48-19.486%2019.48a19.54%2019.54%200%200%201-9.144-2.277l-9.875%202.589a.5.5%200%200%201-.127.017%22/%3E%3Cpath%20fill%3D%22%23cfd8dc%22%20d%3D%22M24.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311l-10.065%202.64%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205m0-1C12.998%204%204.032%2012.962%204.027%2023.979a20%2020%200%200%200%202.461%209.622L3.903%2043.04a.998.998%200%200%200%201.219%201.231l9.687-2.54a20%2020%200%200%200%209.197%202.244c11.024%200%2019.99-8.963%2019.995-19.98A19.86%2019.86%200%200%200%2038.153%209.86%2019.87%2019.87%200%200%200%2024.014%204%22/%3E%3Cpath%20fill%3D%22%2340c351%22%20d%3D%22M35.176%2012.832a15.67%2015.67%200%200%200-11.157-4.626c-8.704%200-15.783%207.076-15.787%2015.774a15.74%2015.74%200%200%200%202.413%208.396l.376.597-1.595%205.821%205.973-1.566.577.342a15.75%2015.75%200%200%200%208.032%202.199h.006c8.698%200%2015.777-7.077%2015.78-15.776a15.68%2015.68%200%200%200-4.618-11.161%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M19.268%2016.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011s-.83.119-1.265.594-1.661%201.622-1.661%203.956%201.7%204.59%201.937%204.906%203.282%205.259%208.104%207.161c4.007%201.58%204.823%201.266%205.693%201.187s2.807-1.147%203.202-2.255.395-2.057.277-2.255c-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543-.751-.237-1.068.238c-.316.474-1.225%201.543-1.502%201.859s-.554.357-1.028.119-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791s.079-.594-.04-.831c-.117-.238-1.039-2.584-1.461-3.522%22/%3E%3C/svg%3E';
const props = withDefaults(
defineProps<{
active: boolean;
sender?: string;
message?: string;
isOutgoing?: boolean;
slideFrom?: 'left' | 'right';
}>(),
{
sender: 'Customer',
message: 'How do I reset my password?',
isOutgoing: false,
slideFrom: 'right',
},
);
const emit = defineEmits<{ complete: [] }>();
const visible = ref(false);
const bubbleVisible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
bubbleVisible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
bubbleVisible.value = true;
}, APPEAR_DELAY_MS + BUBBLE_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
visible.value = false;
bubbleVisible.value = false;
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div
:class="[$style.card, visible && $style.cardVisible, slideFrom === 'left' && $style.slideLeft]"
>
<div :class="$style.header">
<div :class="$style.avatar">
<img :src="WHATSAPP_ICON" :class="$style.avatarIcon" alt="" />
</div>
<span :class="$style.sender">{{ sender }}</span>
</div>
<div v-if="bubbleVisible" :class="[$style.bubbleRow, isOutgoing && $style.bubbleRowOutgoing]">
<div :class="[$style.bubble, isOutgoing ? $style.bubbleOutgoing : $style.bubbleIncoming]">
<p :class="$style.bubbleText">{{ message }}</p>
<svg
v-if="isOutgoing"
:class="$style.readTicks"
width="18"
height="12"
viewBox="0 0 18 12"
fill="none"
>
<path
d="M1 6 L4.5 9.5 L10 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5 6 L8.5 9.5 L14 2"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.card {
width: 280px;
padding: var(--spacing--sm) var(--spacing--md);
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.08),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.slideLeft {
transform: translateX(-8px);
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: #25d366;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
.avatarIcon {
width: 100%;
height: 100%;
}
.sender {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
}
.bubbleRow {
display: flex;
justify-content: flex-start;
}
.bubbleRowOutgoing {
justify-content: flex-end;
}
.bubble {
max-width: 85%;
border-radius: 8px;
padding: 6px 10px;
animation: bubble-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.bubbleIncoming {
background: light-dark(#ebebeb, #3a3a3a);
border-bottom-left-radius: 2px;
}
.bubbleOutgoing {
background: light-dark(#d9fdd3, #1a4a2a);
border-bottom-right-radius: 2px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.bubbleText {
margin: 0;
font-size: var(--font-size--xs);
color: var(--color--text--base);
line-height: 1.4;
}
.readTicks {
color: #8696a0;
flex-shrink: 0;
}
@keyframes bubble-pop {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@ -29,16 +29,6 @@ export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewS
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt' as BaseTextKey,
workflowFile: 'process-invoices',
},
{
type: 'prompt',
id: 'whatsapp-support',
icon: 'message-circle' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt' as BaseTextKey,
workflowFile: 'whatsapp-support',
},
{
type: 'prompt',
id: 'schedule-social-posts',
@ -49,4 +39,14 @@ export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewS
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt' as BaseTextKey,
workflowFile: 'schedule-social-posts',
},
{
type: 'prompt',
id: 'telegram-agent',
icon: 'bot' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.telegramAgent.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.telegramAgent.prompt' as BaseTextKey,
workflowFile: 'telegram-agent',
},
];

View File

@ -1,8 +1,8 @@
import type { PreviewWorkflow } from './types';
import { scoreMyLeadsWorkflow } from './score-my-leads';
import { processInvoicesWorkflow } from './process-invoices';
import { whatsappSupportWorkflow } from './whatsapp-support';
import { scheduleSocialPostsWorkflow } from './schedule-social-posts';
import { telegramAgentWorkflow } from './telegram-agent';
export type {
PreviewWorkflow,
@ -18,8 +18,8 @@ export type {
const workflowRegistry: Record<string, PreviewWorkflow> = {
'score-my-leads': scoreMyLeadsWorkflow,
'process-invoices': processInvoicesWorkflow,
'whatsapp-support': whatsappSupportWorkflow,
'schedule-social-posts': scheduleSocialPostsWorkflow,
'telegram-agent': telegramAgentWorkflow,
};
export function getPreviewWorkflow(workflowFile: string): PreviewWorkflow | undefined {

View File

@ -1,12 +1,12 @@
import type { PreviewWorkflow } from './types';
const GMAIL_ICON_SVG =
export const GMAIL_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22256%22%20height=%22193%22%20preserveAspectRatio=%22xMidYMid%22%3E%3Cpath%20fill=%22%234285F4%22%20d=%22M58.182%20192.05V93.14L27.507%2065.077%200%2049.504v125.091c0%209.658%207.825%2017.455%2017.455%2017.455z%22/%3E%3Cpath%20fill=%22%2334A853%22%20d=%22M197.818%20192.05h40.727c9.659%200%2017.455-7.826%2017.455-17.455V49.505l-31.156%2017.837-27.026%2025.798z%22/%3E%3Cpath%20fill=%22%23EA4335%22%20d=%22m58.182%2093.14-4.174-38.647%204.174-36.989L128%2069.868l69.818-52.364%204.67%2034.992-4.67%2040.644L128%20145.504z%22/%3E%3Cpath%20fill=%22%23FBBC04%22%20d=%22M197.818%2017.504V93.14L256%2049.504V26.231c0-21.585-24.64-33.89-41.89-20.945z%22/%3E%3Cpath%20fill=%22%23C5221F%22%20d=%22m0%2049.504%2026.759%2020.07L58.182%2093.14V17.504L41.89%205.286C24.61-7.66%200%204.646%200%2026.23z%22/%3E%3C/svg%3E';
const ANTHROPIC_ICON_SVG =
export const ANTHROPIC_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2246%22%20height=%2232%22%20fill=%22none%22%3E%3Cpath%20fill=%22%237D7D87%22%20d=%22M32.73%200h-6.945L38.45%2032h6.945zM12.665%200%200%2032h7.082l2.59-6.72h13.25l2.59%206.72h7.082L19.929%200zm-.702%2019.337%204.334-11.246%204.334%2011.246z%22/%3E%3C/svg%3E';
const GOOGLE_SHEETS_ICON_SVG =
export const GOOGLE_SHEETS_ICON_SVG =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2260%22%20height=%2260%22%3E%3Cg%20fill=%22none%22%20fill-rule=%22evenodd%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M35.69%201%2052%2017.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2048.293%2060H12.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%209%2056.312V4.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2012.707%201z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M35.69%201%2052%2017.225H39.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M39.211%2017.225%2052%2022.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M20.12%2031.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H21.603a1.476%201.476%200%200%201-1.482-1.474V31.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M34.69%200%2051%2016.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2047.293%2059H11.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%208%2055.312V3.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2011.707%200z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M34.69%200%2051%2016.225H38.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M38.211%2016.225%2051%2021.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M19.12%2030.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H20.603a1.476%201.476%200%200%201-1.482-1.474V30.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3C/g%3E%3C/svg%3E';
const GOOGLE_CALENDAR_ICON_SVG =

View File

@ -0,0 +1,9 @@
import type { PreviewWorkflow } from './types';
export const telegramAgentWorkflow: PreviewWorkflow = {
nodes: [],
connections: [],
fullCanvasVisualization: {
type: 'agent-builder',
},
};

View File

@ -22,7 +22,7 @@ export type PreviewVisualizationType =
| 'slack-message'
| 'salesforce-card'
| 'invoice-spreadsheet'
| 'whatsapp-chat';
| 'agent-builder';
export interface PreviewVisualization {
type: PreviewVisualizationType;
@ -51,5 +51,6 @@ export interface PreviewWorkflow {
connections: PreviewWorkflowConnection[];
inputVisualization?: PreviewVisualization;
outputVisualization?: PreviewVisualization | PreviewOutputVisualization[];
fullCanvasVisualization?: PreviewVisualization;
crmCycle?: CrmCycleConfig;
}

View File

@ -431,8 +431,9 @@ const CANVAS_NATURAL_HEIGHT_PX = 420;
const PREVIEW_MIN_SCALE = 0.3;
const previewScale = ref(1);
const previewRemainingSpace = ref(CANVAS_NATURAL_HEIGHT_PX);
useResizeObserver(emptyLayoutRef, () => {
function updatePreviewScale() {
if (!emptyLayoutRef.value || !centeredInputRef.value) return;
const containerRect = emptyLayoutRef.value.getBoundingClientRect();
const inputRect = centeredInputRef.value.getBoundingClientRect();
@ -440,15 +441,20 @@ useResizeObserver(emptyLayoutRef, () => {
const bottomPadding = parseFloat(layoutStyles.paddingBottom);
const gap = parseFloat(layoutStyles.gap) || 0;
const remainingSpace = containerRect.bottom - inputRect.bottom - bottomPadding - gap;
previewRemainingSpace.value = Math.max(0, remainingSpace);
previewScale.value = Math.max(0, Math.min(1, remainingSpace / CANVAS_NATURAL_HEIGHT_PX));
});
}
useResizeObserver(emptyLayoutRef, updatePreviewScale);
useResizeObserver(centeredInputRef, updatePreviewScale);
const hasSpaceForPreview = computed(() => previewScale.value >= PREVIEW_MIN_SCALE);
const workflowPreviewWrapperStyle = computed(() => ({
transform: `scale(${previewScale.value})`,
transformOrigin: 'top center',
height: `${CANVAS_NATURAL_HEIGHT_PX * previewScale.value}px`,
height: `${previewRemainingSpace.value}px`,
'--workflow-preview-canvas-height': `${Math.max(CANVAS_NATURAL_HEIGHT_PX, previewRemainingSpace.value)}px`,
}));
useChatInputAutoFocus(chatInputRef, { disabled: isStartingThread });