feat: Implements AI Assistant empty state workflow previews experiment (#31519)

This commit is contained in:
Joco-95 2026-06-03 15:34:54 +02:00 committed by GitHub
parent 5504361d0f
commit ef3a5606e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2308 additions and 5 deletions

View File

@ -1239,6 +1239,17 @@
"experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.",
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails",
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.",
"experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title": "What do you want to automate?",
"experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder": "Tell me what to build or ask me a question",
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label": "Score my leads",
"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.seeAll": "See all",
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",

View File

@ -118,6 +118,10 @@ export const SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT = createExperiment(
export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping');
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT = createExperiment(
'087_instance_ai_workflow_preview_suggestions',
);
export const EXPERIMENTS_TO_TRACK = [
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name,
@ -148,4 +152,5 @@ export const EXPERIMENTS_TO_TRACK = [
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name,
CANVAS_NODES_GROUPING_EXPERIMENT.name,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name,
];

View File

@ -0,0 +1,517 @@
<script lang="ts" setup>
import { computed, reactive, ref, onMounted, onUnmounted, watch, type Component } from 'vue';
import { useElementSize } from '@vueuse/core';
import type {
PreviewWorkflow,
PreviewWorkflowConnection,
PreviewVisualizationType,
PreviewOutputVisualization,
} from '../workflows/types';
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';
const NODE_HALF_WIDTH = 48;
const EDGE_CURVE_OFFSET = 60;
const PADDING = 80;
const CANVAS_HEIGHT = 420;
const NODE_LABEL_OFFSET = 13;
const NODE_RUNNING_DURATION_MS = 250;
export type NodeAnimationState = 'idle' | 'running' | 'success';
export type AnimationPhase = 'idle' | 'input' | 'nodes' | 'output' | 'done';
const visualizationComponents: Record<PreviewVisualizationType, Component> = {
'slack-message': SlackMessageVisualization,
'salesforce-card': SalesforceCardVisualization,
'invoice-spreadsheet': InvoiceSpreadsheetVisualization,
'whatsapp-chat': WhatsAppChatVisualization,
};
const props = withDefaults(
defineProps<{
workflow: PreviewWorkflow;
animating?: boolean;
}>(),
{ animating: true },
);
const canvasRef = ref<HTMLElement | null>(null);
const { width: canvasRenderedWidth } = 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 outputVizItems = computed((): PreviewOutputVisualization[] => {
const viz = props.workflow.outputVisualization;
if (!viz) return [];
if (Array.isArray(viz)) return viz;
const last = lastNode.value;
if (!last) return [];
return [{ type: viz.type, props: viz.props, targetNodeId: last.id }];
});
const inputVizComponent = computed(() =>
props.workflow.inputVisualization
? visualizationComponents[props.workflow.inputVisualization.type]
: undefined,
);
const executionSteps = computed(() => {
const { nodes, connections } = props.workflow;
const incomingMap = new Map<string, Set<string>>();
for (const node of nodes) {
incomingMap.set(node.id, new Set());
}
for (const conn of connections) {
incomingMap.get(conn.target)?.add(conn.source);
}
const visited = new Set<string>();
const steps: string[][] = [];
while (visited.size < nodes.length) {
const ready = nodes
.filter((n) => !visited.has(n.id))
.filter((n) => {
const deps = incomingMap.get(n.id);
return !deps || [...deps].every((d) => visited.has(d));
})
.map((n) => n.id);
if (ready.length === 0) break;
steps.push(ready);
for (const id of ready) visited.add(id);
}
return steps;
});
const firstNode = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return undefined;
return nodes.reduce((min, n) => (n.position.x < min.position.x ? n : min), nodes[0]);
});
const lastNode = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return undefined;
return nodes.reduce((max, n) => (n.position.x > max.position.x ? n : max), nodes[0]);
});
const triggerNodeIds = computed(() => {
const targets = new Set(props.workflow.connections.map((c) => c.target));
return new Set(props.workflow.nodes.filter((n) => !targets.has(n.id)).map((n) => n.id));
});
function resetStates() {
for (const node of props.workflow.nodes) {
nodeStates[node.id] = 'idle';
}
}
let animationTimer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
animationTimer = setTimeout(resolve, ms);
});
}
let outputCompletedCount = 0;
async function runNodeAnimation() {
for (const step of executionSteps.value) {
if (stopped) return;
for (const id of step) nodeStates[id] = 'running';
await sleep(NODE_RUNNING_DURATION_MS);
if (stopped) return;
for (const id of step) nodeStates[id] = 'success';
}
if (stopped) return;
if (hasOutputViz.value) {
outputCompletedCount = 0;
animationPhase.value = 'output';
} else {
animationPhase.value = 'done';
}
}
function startAnimation() {
stopped = false;
resetStates();
if (hasInputViz.value) {
animationPhase.value = 'input';
} else {
animationPhase.value = 'nodes';
void runNodeAnimation();
}
}
function stopAnimation() {
stopped = true;
if (animationTimer) {
clearTimeout(animationTimer);
animationTimer = null;
}
}
function handleInputComplete() {
if (stopped) return;
animationPhase.value = 'nodes';
void runNodeAnimation();
}
function handleOutputComplete() {
if (stopped) return;
outputCompletedCount++;
if (outputCompletedCount >= outputVizItems.value.length) {
animationPhase.value = 'done';
}
}
watch(
() => props.animating,
(val) => {
if (val) {
startAnimation();
} else {
stopAnimation();
resetStates();
animationPhase.value = 'idle';
}
},
);
onMounted(() => {
resetStates();
if (props.animating) {
startAnimation();
}
});
onUnmounted(stopAnimation);
// CRM icons cycling logic (score-my-leads example)
const crmCycleIndex = ref(0);
const crmCycleVisible = ref(false);
let crmCycleStartTimer: ReturnType<typeof setTimeout> | null = null;
let crmCycleInterval: ReturnType<typeof setInterval> | null = null;
const hasCrmCycle = computed(() => !!props.workflow.crmCycle);
const crmVariants = computed(() => props.workflow.crmCycle?.variants ?? []);
const crmCurrentVariant = computed(() => crmVariants.value[crmCycleIndex.value]);
const crmCycleNodeIds = computed(() => new Set(props.workflow.crmCycle?.nodeIds ?? []));
const CRM_INITIAL_DELAY_MS = 500;
const CRM_INTERVAL_MS = 1400;
function startCrmCycle() {
if (!hasCrmCycle.value) return;
crmCycleVisible.value = true;
crmCycleIndex.value = 0;
const interval = props.workflow.crmCycle?.intervalMs ?? CRM_INTERVAL_MS;
crmCycleStartTimer = setTimeout(() => {
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
crmCycleInterval = setInterval(() => {
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
}, interval);
}, CRM_INITIAL_DELAY_MS);
}
function stopCrmCycle() {
crmCycleVisible.value = false;
if (crmCycleStartTimer) {
clearTimeout(crmCycleStartTimer);
crmCycleStartTimer = null;
}
if (crmCycleInterval) {
clearInterval(crmCycleInterval);
crmCycleInterval = null;
}
}
watch(animationPhase, (phase) => {
if (phase === 'done' && hasCrmCycle.value) {
startCrmCycle();
} else {
stopCrmCycle();
}
});
onUnmounted(stopCrmCycle);
const inputVizIcon = computed(() => {
if (crmCycleVisible.value && crmCurrentVariant.value) {
return crmCurrentVariant.value.icon.src;
}
return undefined;
});
const bounds = computed(() => {
const nodes = props.workflow.nodes;
if (nodes.length === 0) return { minX: 0, minY: 0, width: 400, height: 200 };
const xs = nodes.map((n) => n.position.x);
const ys = nodes.map((n) => n.position.y);
const minX = Math.min(...xs) - PADDING;
const minY = Math.min(...ys) - PADDING;
const maxX = Math.max(...xs) + PADDING;
const maxY = Math.max(...ys) + PADDING;
return { minX, minY, width: maxX - minX, height: maxY - minY };
});
const CANVAS_WIDTH = 1600;
const VIZ_MARGIN = 160;
const VIZ_GAP = 28;
const CANVAS_INNER_PADDING = 60;
const scale = computed(() => {
const { width, height } = bounds.value;
let availableWidth = CANVAS_WIDTH - 2 * CANVAS_INNER_PADDING;
if (hasInputViz.value) availableWidth -= VIZ_MARGIN;
if (hasOutputViz.value) availableWidth -= VIZ_MARGIN;
const scaleX = availableWidth / width;
const scaleY = (CANVAS_HEIGHT - 2 * CANVAS_INNER_PADDING) / height;
return Math.min(scaleX, scaleY, 1);
});
const effectiveCanvasWidth = computed(() =>
canvasRenderedWidth.value > 0 ? canvasRenderedWidth.value : CANVAS_WIDTH,
);
const containerStyle = computed(() => {
const { width, height } = bounds.value;
const s = scale.value;
const scaledWidth = width * s;
const scaledHeight = height * s;
return {
width: `${width}px`,
height: `${height}px`,
transform: `scale(${s})`,
transformOrigin: 'top left',
left: `${(effectiveCanvasWidth.value - scaledWidth) / 2}px`,
top: `${(CANVAS_HEIGHT - scaledHeight) / 2}px`,
};
});
const viewBox = computed(
() => `${bounds.value.minX} ${bounds.value.minY} ${bounds.value.width} ${bounds.value.height}`,
);
const canvasMarginLeft = computed(() => {
const s = scale.value;
const scaledWidth = bounds.value.width * s;
return (effectiveCanvasWidth.value - scaledWidth) / 2;
});
const canvasMarginTop = computed(() => {
const s = scale.value;
const scaledHeight = bounds.value.height * s;
return (CANVAS_HEIGHT - scaledHeight) / 2;
});
function toScreenX(workflowX: number): number {
return (workflowX - bounds.value.minX) * scale.value + canvasMarginLeft.value;
}
function toScreenY(workflowY: number): number {
return (workflowY - bounds.value.minY) * scale.value + canvasMarginTop.value;
}
const inputSlotStyle = computed(() => {
if (!firstNode.value) return {};
const nodeScreenX = toScreenX(firstNode.value.position.x - NODE_HALF_WIDTH);
const nodeScreenY = toScreenY(firstNode.value.position.y - NODE_LABEL_OFFSET);
return {
left: `${nodeScreenX - VIZ_GAP}px`,
top: `${nodeScreenY}px`,
transform: 'translateX(-100%) translateY(-50%)',
};
});
const OUTPUT_VIZ_HEIGHT = 80;
const OUTPUT_VIZ_GAP = 16;
const outputSlotStyles = computed(() => {
const items = outputVizItems.value;
if (items.length === 0) return [];
const positions = items.map((item) => {
const node = props.workflow.nodes.find((n) => n.id === item.targetNodeId);
const targetNode = node ?? lastNode.value;
if (!targetNode) return { x: 0, y: 0 };
return {
x: toScreenX(targetNode.position.x + NODE_HALF_WIDTH),
y: toScreenY(targetNode.position.y - NODE_LABEL_OFFSET),
};
});
if (items.length > 1) {
const sorted = positions.map((p, i) => ({ idx: i, y: p.y })).sort((a, b) => a.y - b.y);
const minSpacing = OUTPUT_VIZ_HEIGHT + OUTPUT_VIZ_GAP;
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const curr = sorted[i];
if (curr.y - prev.y < minSpacing) {
positions[curr.idx].y = positions[prev.idx].y + minSpacing;
}
}
}
const isMulti = items.length > 1;
return positions.map((pos) => ({
left: `${pos.x + VIZ_GAP}px`,
top: `${pos.y}px`,
...(isMulti ? {} : { transform: 'translateY(-50%)' }),
}));
});
function getEdgePath(connection: PreviewWorkflowConnection): string {
const sourceNode = props.workflow.nodes.find((n) => n.id === connection.source);
const targetNode = props.workflow.nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) return '';
const sx = sourceNode.position.x + NODE_HALF_WIDTH;
const sy = sourceNode.position.y - NODE_LABEL_OFFSET;
const tx = targetNode.position.x - NODE_HALF_WIDTH;
const ty = targetNode.position.y - NODE_LABEL_OFFSET;
const dist = Math.abs(tx - sx);
const cx = Math.min(EDGE_CURVE_OFFSET, dist * 0.4);
return `M ${sx} ${sy} C ${sx + cx} ${sy}, ${tx - cx} ${ty}, ${tx} ${ty}`;
}
function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
return nodeStates[connection.target] === 'success';
}
</script>
<template>
<div ref="canvasRef" :class="$style.canvas">
<div :class="$style.viewport" :style="containerStyle">
<svg :class="$style.edges" :viewBox="viewBox">
<path
v-for="(conn, idx) in props.workflow.connections"
:key="`edge-${idx}`"
:d="getEdgePath(conn)"
:class="[$style.edge, isEdgeSuccess(conn) && $style.edgeSuccess]"
/>
</svg>
<div :class="$style.nodesLayer">
<WorkflowPreviewNode
v-for="node in props.workflow.nodes"
:key="node.id"
:node="node"
:state="nodeStates[node.id] ?? 'idle'"
:trigger="triggerNodeIds.has(node.id)"
:offset-x="bounds.minX"
:offset-y="bounds.minY"
:icon-override="
crmCycleNodeIds.has(node.id) && crmCycleVisible ? crmCurrentVariant?.icon : undefined
"
/>
</div>
</div>
<div v-if="inputVizComponent" :class="$style.vizSlot" :style="inputSlotStyle">
<component
:is="inputVizComponent"
:active="animationPhase !== 'idle'"
v-bind="props.workflow.inputVisualization?.props"
:icon-override="inputVizIcon"
slide-from="left"
@complete="handleInputComplete"
/>
</div>
<div
v-for="(outputViz, idx) in outputVizItems"
:key="`output-viz-${idx}`"
:class="[$style.vizSlot, outputVizItems.length > 1 && $style.vizSlotCompact]"
:style="outputSlotStyles[idx]"
>
<component
:is="visualizationComponents[outputViz.type]"
:active="animationPhase === 'output' || animationPhase === 'done'"
v-bind="outputViz.props"
:icon-override="
outputViz.type === 'salesforce-card' && crmCycleVisible ? inputVizIcon : undefined
"
@complete="handleOutputComplete"
/>
</div>
</div>
</template>
<style lang="scss" module>
.canvas {
position: relative;
width: 100%;
max-width: 1600px;
height: 420px;
margin: 0 auto;
overflow: hidden;
background-color: var(--canvas--color--background);
background-image: radial-gradient(
oklch(from var(--canvas--dot--color) l c h / 0.5) 1px,
transparent 1px
);
background-size: 16px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius--xl);
}
.viewport {
position: absolute;
}
.edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.edge {
fill: none;
stroke: var(--color--foreground--shade-1);
stroke-width: 2;
stroke-linecap: round;
transition: stroke 0.3s ease;
}
.edgeSuccess {
stroke: var(--color--success);
}
.nodesLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.vizSlot {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.vizSlotCompact {
transform: translateY(-50%);
transform-origin: left center;
}
</style>

View File

@ -0,0 +1,188 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { N8nIcon } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import type { PreviewWorkflowNode, PreviewWorkflowNodeIcon } from '../workflows/types';
import type { NodeAnimationState } from './WorkflowPreviewCanvas.vue';
const props = withDefaults(
defineProps<{
node: PreviewWorkflowNode;
offsetX: number;
offsetY: number;
state?: NodeAnimationState;
trigger?: boolean;
iconOverride?: PreviewWorkflowNodeIcon;
}>(),
{ state: 'idle', trigger: false },
);
const style = computed(() => ({
left: `${props.node.position.x - props.offsetX}px`,
top: `${props.node.position.y - props.offsetY}px`,
}));
const iconWrapperStyle = computed(() => {
if (props.node.iconColor) {
return { color: `var(--node--icon--color--${props.node.iconColor})` };
}
return {};
});
const activeIcon = computed(() => props.iconOverride ?? props.node.icon);
const iconName = computed(() =>
activeIcon.value.type === 'icon' ? (activeIcon.value.name as IconName) : undefined,
);
const iconSrc = computed(() =>
activeIcon.value.type === 'file' ? activeIcon.value.src : undefined,
);
</script>
<template>
<div
:class="[
$style.node,
props.trigger && $style.trigger,
props.state === 'running' && $style.running,
props.state === 'success' && $style.success,
]"
:style="style"
>
<div :class="$style.iconWrapper" :style="iconWrapperStyle">
<N8nIcon v-if="iconName" :icon="iconName" :size="48" />
<Transition v-else-if="iconSrc" :name="$style.swipe" mode="out-in">
<img :key="iconSrc" :src="iconSrc" :class="$style.iconImage" />
</Transition>
<span v-else :class="$style.iconFallback">{{ props.node.label.charAt(0) }}</span>
</div>
<span :class="$style.label">{{ props.node.label }}</span>
</div>
</template>
<style lang="scss" module>
.node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
transform: translate(-50%, -50%);
}
.iconWrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: var(--radius--lg);
background: var(--node--color--background, var(--color--background--light-3));
border: 1.5px solid
light-dark(
oklch(from var(--color--neutral-black) l c h / 0.1),
oklch(from var(--color--neutral-white) l c h / 0.15)
);
color: var(--node--icon--color, var(--color--foreground--shade-1));
transition: border-color 0.2s ease;
&::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 10px;
z-index: -1;
opacity: 0;
background: conic-gradient(
from var(--node--gradient-angle),
rgba(255, 109, 90, 1),
rgba(255, 109, 90, 1) 20%,
rgba(255, 109, 90, 0.2) 35%,
rgba(255, 109, 90, 0.2) 65%,
rgba(255, 109, 90, 1) 90%,
rgba(255, 109, 90, 1)
);
transition: opacity 0.15s ease;
}
}
.trigger .iconWrapper {
border-radius: 36px var(--radius--lg) var(--radius--lg) 36px;
&::after {
border-radius: 36px 10px 10px 36px;
}
}
.running .iconWrapper {
border-color: transparent;
&::after {
opacity: 1;
animation: border-rotate 1.5s linear infinite;
}
}
.success .iconWrapper {
border-width: 2px;
border-color: var(--color--success);
}
.iconImage {
max-width: 48px;
max-height: 48px;
width: auto;
height: auto;
}
.iconFallback {
font-size: var(--font-size--2xl);
font-weight: var(--font-weight--bold);
}
.label {
margin-top: var(--spacing--2xs);
font-size: var(--font-size--md);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--sm);
color: var(--color--text--base);
white-space: nowrap;
max-width: 192px;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.swipe:global(-enter-active),
.swipe:global(-leave-active) {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.swipe:global(-enter-from) {
transform: translateX(16px);
opacity: 0;
}
.swipe:global(-leave-to) {
transform: translateX(-16px);
opacity: 0;
}
@property --node--gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes border-rotate {
from {
--node--gradient-angle: 0deg;
}
to {
--node--gradient-angle: 360deg;
}
}
</style>

View File

@ -0,0 +1,191 @@
<script lang="ts" setup>
import { N8nIcon } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { onUnmounted, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type WorkflowPreviewSuggestion } from '../suggestions';
const PREVIEW_HOVER_DELAY_MS = 300;
const props = defineProps<{
suggestions: readonly WorkflowPreviewSuggestion[];
disabled: boolean;
}>();
interface SubmitSuggestionPayload {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt';
position: number;
}
const emit = defineEmits<{
'preview-change': [promptKey: BaseTextKey | null];
'submit-suggestion': [payload: SubmitSuggestionPayload];
'workflow-preview': [workflowFile: string | null];
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const activePreview = ref<string | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
const hoverStartTimes = new Map<string, number>();
function clearHoverTimer() {
if (!hoverTimer) return;
clearTimeout(hoverTimer);
hoverTimer = null;
}
function trackHoverEnd(suggestionId: string) {
const startTime = hoverStartTimes.get(suggestionId);
if (startTime === undefined) return;
hoverStartTimes.delete(suggestionId);
telemetry.track('AI Assistant suggestion button hovered', {
suggestion_id: suggestionId,
seconds: Math.floor((Date.now() - startTime) / 1000),
});
}
function clearPreview() {
clearHoverTimer();
for (const id of hoverStartTimes.keys()) {
trackHoverEnd(id);
}
activePreview.value = null;
emit('preview-change', null);
emit('workflow-preview', null);
}
function handleSuggestionEnter(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
hoverStartTimes.set(suggestion.id, Date.now());
clearHoverTimer();
hoverTimer = setTimeout(() => {
hoverTimer = null;
activePreview.value = suggestion.id;
emit('preview-change', suggestion.promptKey);
emit('workflow-preview', suggestion.workflowFile);
}, PREVIEW_HOVER_DELAY_MS);
}
function handleSuggestionFocus(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
clearHoverTimer();
activePreview.value = suggestion.id;
emit('preview-change', suggestion.promptKey);
emit('workflow-preview', suggestion.workflowFile);
}
function handleSuggestionClick(suggestion: WorkflowPreviewSuggestion) {
if (props.disabled) return;
const position = props.suggestions.indexOf(suggestion) + 1;
telemetry.track('AI Assistant suggestion button clicked', {
suggestion_id: suggestion.id,
});
clearPreview();
emit('submit-suggestion', {
promptKey: suggestion.promptKey,
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position,
});
}
onUnmounted(clearPreview);
</script>
<template>
<div :class="$style.suggestions" data-test-id="instance-ai-workflow-preview-suggestions">
<div :class="$style.suggestionRow">
<button
v-for="(suggestion, index) in props.suggestions"
:key="suggestion.id"
type="button"
:class="[
$style.suggestionButton,
activePreview === suggestion.id && $style.suggestionButtonActive,
]"
:style="{ animationDelay: `${index * 50}ms` }"
:data-test-id="`instance-ai-suggestion-${suggestion.id}`"
:disabled="props.disabled"
@click="handleSuggestionClick(suggestion)"
@mouseenter="handleSuggestionEnter(suggestion)"
@mouseleave="clearPreview"
@focus="handleSuggestionFocus(suggestion)"
@blur="clearPreview"
>
<N8nIcon :icon="suggestion.icon" :size="12" :class="$style.suggestionIcon" />
<span>{{ i18n.baseText(suggestion.labelKey) }}</span>
</button>
<a
href="https://n8n.io/workflows/"
target="_blank"
rel="noopener noreferrer"
:class="$style.seeAllLink"
>
<span>{{
i18n.baseText(
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll' as BaseTextKey,
)
}}</span>
<N8nIcon icon="arrow-right" :size="12" />
</a>
</div>
</div>
</template>
<style module lang="scss">
@use '@/features/ai/shared/styles/prompt-suggestion-buttons' as promptSuggestions;
.suggestions {
width: 100%;
}
.suggestionRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing--2xs);
width: 100%;
}
.suggestionButton {
@include promptSuggestions.prompt-suggestion-button;
flex: 0 0 auto;
white-space: nowrap;
}
.suggestionButtonActive {
@include promptSuggestions.prompt-suggestion-button-active;
}
.suggestionIcon {
@include promptSuggestions.prompt-suggestion-icon;
.suggestionButton:hover &,
.suggestionButton:focus-visible & {
opacity: 1;
}
}
.seeAllLink {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
white-space: nowrap;
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@ -0,0 +1,235 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const ROW_DELAY_MS = 450;
const CHECK_DELAY_MS = ROW_DELAY_MS + 350;
const COMPLETE_DELAY_MS = CHECK_DELAY_MS + 650;
const props = defineProps<{ active: boolean }>();
const emit = defineEmits<{ complete: [] }>();
const visible = ref(false);
const rowVisible = ref(false);
const checkVisible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
rowVisible.value = false;
checkVisible.value = false;
const base = APPEAR_DELAY_MS;
timers.push(
setTimeout(() => {
visible.value = true;
}, base),
);
timers.push(
setTimeout(() => {
rowVisible.value = true;
}, base + ROW_DELAY_MS),
);
timers.push(
setTimeout(() => {
checkVisible.value = true;
}, base + CHECK_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, base + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) {
runAnimation();
} else {
clearTimers();
visible.value = false;
rowVisible.value = false;
checkVisible.value = false;
}
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="[$style.card, visible && $style.cardVisible]">
<div :class="$style.table">
<div :class="[$style.row, $style.rowHeader]">
<span :class="[$style.cell, $style.th]">Invoice</span>
<span :class="[$style.cell, $style.th]">Date</span>
<span :class="[$style.cell, $style.th]">Discrepancy</span>
</div>
<div :class="$style.row">
<span :class="$style.cell">INV-2024-045</span>
<span :class="$style.cell">May 14</span>
<span :class="[$style.cell, $style.tdMuted]"></span>
</div>
<div :class="$style.row">
<span :class="$style.cell">INV-2024-046</span>
<span :class="$style.cell">May 21</span>
<span :class="[$style.cell, $style.tdMuted]"></span>
</div>
<div :class="[$style.rowCollapse, rowVisible && $style.rowCollapseOpen]">
<div :class="[$style.row, $style.rowLast]">
<span :class="$style.cell">INV-2024-047</span>
<span :class="$style.cell">May 29</span>
<span :class="[$style.cell, $style.tdDiscrepancy]">
<svg
v-if="checkVisible"
viewBox="0 0 18 18"
width="13"
height="13"
:class="$style.checkIcon"
>
<path
d="M2.5 9.5 L7 14 L15.5 4"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
:class="$style.checkPath"
/>
</svg>
</span>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" module>
$border: light-dark(
oklch(from var(--color--neutral-black) l c h / 0.1),
oklch(from var(--color--neutral-white) l c h / 0.12)
);
.card {
width: 280px;
padding: 0;
background: var(--color--background--light-3);
border-radius: var(--radius--lg);
border: 1px solid $border;
display: flex;
flex-direction: column;
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
overflow: hidden;
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.table {
width: 100%;
}
.row {
display: grid;
grid-template-columns: 2fr 1.2fr 1.3fr;
}
.rowHeader {
background: light-dark(
oklch(from var(--color--neutral-black) l c h / 0.04),
oklch(from var(--color--neutral-white) l c h / 0.06)
);
}
.rowCollapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.rowCollapseOpen {
grid-template-rows: 1fr;
}
.rowLast {
min-height: 0;
overflow: hidden;
}
.cell {
font-size: var(--font-size--2xs);
line-height: 13px;
color: var(--color--text--base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 6px 9px;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
&:last-child {
border-right: none;
}
}
.row:last-of-type .cell {
border-bottom: none;
}
.rowLast .cell {
border-bottom: none;
}
.th {
font-size: 10px;
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tdMuted {
color: var(--color--text--tint-1);
}
.tdDiscrepancy {
display: flex;
align-items: center;
color: var(--color--warning);
}
.checkIcon {
overflow: visible;
flex-shrink: 0;
}
.checkPath {
stroke-dasharray: 20;
stroke-dashoffset: 20;
animation: draw-check 0.45s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes draw-check {
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@ -0,0 +1,156 @@
<script lang="ts" setup>
import { SALESFORCE_ICON_SVG } from '../../workflows/score-my-leads';
import { computed, ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const COMPLETE_DELAY_MS = 600;
const props = withDefaults(
defineProps<{
active: boolean;
title?: string;
subtitle?: string;
slideFrom?: 'left' | 'right';
icon?: string;
iconOverride?: string;
}>(),
{
title: 'New lead',
subtitle: 'John Doe',
slideFrom: 'right',
},
);
const currentIcon = computed(() => props.iconOverride ?? props.icon ?? SALESFORCE_ICON_SVG);
const emit = defineEmits<{
complete: [];
}>();
const visible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) runAnimation();
else clearTimers();
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div
:class="[
$style.card,
visible && $style.cardVisible,
props.slideFrom === 'left' && $style.slideLeft,
]"
>
<Transition :name="$style.swipe" mode="out-in">
<img :key="currentIcon" :class="$style.icon" :src="currentIcon" alt="" />
</Transition>
<div :class="$style.content">
<span :class="$style.title">{{ props.title }}</span>
<span :class="$style.subtitle">{{ props.subtitle }}</span>
</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;
align-items: center;
gap: var(--spacing--sm);
opacity: 0;
transform: translateX(8px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.slideLeft {
transform: translateX(-8px);
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.icon {
width: 36px;
height: 36px;
flex-shrink: 0;
}
.swipe:global(-enter-active),
.swipe:global(-leave-active) {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.swipe:global(-enter-from) {
transform: translateX(16px);
opacity: 0;
}
.swipe:global(-leave-to) {
transform: translateX(-16px);
opacity: 0;
}
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
min-width: 0;
}
.title {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
line-height: 1.4;
}
.subtitle {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,136 @@
<script lang="ts" setup>
import { ref, watch, onUnmounted } from 'vue';
const APPEAR_DELAY_MS = 200;
const COMPLETE_DELAY_MS = 600;
const props = withDefaults(
defineProps<{
active: boolean;
sender?: string;
message?: string;
}>(),
{
sender: 'n8n Bot',
message: 'Urgent ticket: Login page broken',
},
);
const emit = defineEmits<{
complete: [];
}>();
const visible = ref(false);
let timers: Array<ReturnType<typeof setTimeout>> = [];
function clearTimers() {
for (const t of timers) clearTimeout(t);
timers = [];
}
function runAnimation() {
visible.value = false;
timers.push(
setTimeout(() => {
visible.value = true;
}, APPEAR_DELAY_MS),
);
timers.push(
setTimeout(() => {
emit('complete');
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
);
}
watch(
() => props.active,
(val) => {
if (val) runAnimation();
else clearTimers();
},
{ immediate: true },
);
onUnmounted(clearTimers);
</script>
<template>
<div :class="[$style.card, visible && $style.cardVisible]">
<div :class="$style.header">
<div :class="$style.avatar">
<img
:class="$style.avatarIcon"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill='%23fff' fill-rule='evenodd' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 150.852 150.852'%3E%3Cuse xlink:href='%23a' x='.926' y='.926'/%3E%3Csymbol id='a' overflow='visible'%3E%3Cg stroke-width='1.852'%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M40.741 93.55c0-8.735 6.607-15.772 14.815-15.772s14.815 7.037 14.815 15.772v38.824c0 8.737-6.607 15.774-14.815 15.774s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M93.55 107.408c-8.735 0-15.772-6.607-15.772-14.815s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M77.778 15.772C77.778 7.037 84.385 0 92.593 0s14.815 7.037 14.815 15.772v38.826c0 8.735-6.607 15.772-14.815 15.772s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M15.772 70.371C7.037 70.371 0 63.763 0 55.556s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cg stroke-linejoin='miter'%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M77.778 133.333c0 8.208 6.607 14.815 14.815 14.815s14.815-6.607 14.815-14.815-6.607-14.815-14.815-14.815H77.778z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M133.334 70.371h-14.815V55.556c0-8.207 6.607-14.815 14.815-14.815s14.815 6.607 14.815 14.815-6.607 14.815-14.815 14.815z'/%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M14.815 77.778H29.63v14.815c0 8.207-6.607 14.815-14.815 14.815S0 100.8 0 92.593s6.607-14.815 14.815-14.815z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M70.371 14.815V29.63H55.556c-8.207 0-14.815-6.607-14.815-14.815S47.348 0 55.556 0s14.815 6.607 14.815 14.815z'/%3E%3C/g%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E"
alt=""
/>
</div>
<span :class="$style.sender">{{ props.sender }}</span>
</div>
<p :class="$style.message">{{ props.message }}</p>
</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;
}
.cardVisible {
opacity: 1;
transform: translateX(0);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--color--neutral-700);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.avatarIcon {
width: 100%;
height: 100%;
}
.sender {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text--base);
}
.message {
margin: 0;
font-size: var(--font-size--sm);
color: var(--color--text--tint-1);
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,230 @@
<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

@ -0,0 +1,9 @@
export { useInstanceAiWorkflowPreviewSuggestionsExperiment } from './useInstanceAiWorkflowPreviewSuggestionsExperiment';
export {
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
type WorkflowPreviewSuggestion,
} from './suggestions';
export { default as WorkflowPreviewSuggestions } from './components/WorkflowPreviewSuggestions.vue';
export { default as WorkflowPreviewCanvas } from './components/WorkflowPreviewCanvas.vue';
export { getPreviewWorkflow } from './workflows';

View File

@ -0,0 +1,52 @@
import type { IconName } from '@n8n/design-system';
import type { BaseTextKey } from '@n8n/i18n';
import type { InstanceAiEmptyStatePromptSuggestion } from '@/features/ai/instanceAi/emptyStateSuggestions';
export interface WorkflowPreviewSuggestion extends InstanceAiEmptyStatePromptSuggestion {
workflowFile: string;
}
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION = 'v3-workflow-preview';
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewSuggestion[] = [
{
type: 'prompt',
id: 'score-my-leads',
icon: 'badge-check' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt' as BaseTextKey,
workflowFile: 'score-my-leads',
},
{
type: 'prompt',
id: 'process-invoices',
icon: 'file-text' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label' as BaseTextKey,
promptKey:
'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',
icon: 'calendar' as IconName,
labelKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label' as BaseTextKey,
promptKey:
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt' as BaseTextKey,
workflowFile: 'schedule-social-posts',
},
];

View File

@ -0,0 +1,16 @@
import { computed } from 'vue';
import { INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT } from '@/app/constants/experiments';
import { usePostHog } from '@/app/stores/posthog.store';
export function useInstanceAiWorkflowPreviewSuggestionsExperiment() {
const posthogStore = usePostHog();
const isFeatureEnabled = computed(
() =>
posthogStore.getVariant(INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name) ===
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.variant,
);
return { isFeatureEnabled };
}

View File

@ -0,0 +1,27 @@
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';
export type {
PreviewWorkflow,
PreviewWorkflowNode,
PreviewWorkflowConnection,
PreviewVisualization,
PreviewVisualizationType,
PreviewOutputVisualization,
CrmCycleConfig,
CrmCycleVariant,
} from './types';
const workflowRegistry: Record<string, PreviewWorkflow> = {
'score-my-leads': scoreMyLeadsWorkflow,
'process-invoices': processInvoicesWorkflow,
'whatsapp-support': whatsappSupportWorkflow,
'schedule-social-posts': scheduleSocialPostsWorkflow,
};
export function getPreviewWorkflow(workflowFile: string): PreviewWorkflow | undefined {
return workflowRegistry[workflowFile];
}

View File

@ -0,0 +1,79 @@
import type { PreviewWorkflow } from './types';
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 =
'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 =
'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 =
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20xmlns:xlink=%22http://www.w3.org/1999/xlink%22%20fill=%22%23fff%22%20fill-rule=%22evenodd%22%20stroke=%22%23000%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%20viewBox=%220%200%2081%2082%22%3E%3Cuse%20xlink:href=%22%23a%22%20x=%22.5%22%20y=%22.5%22/%3E%3Csymbol%20id=%22a%22%20overflow=%22visible%22%3E%3Cg%20fill-rule=%22nonzero%22%20stroke=%22none%22%3E%3Cpath%20d=%22M61.052%2018.947H18.947v42.105h42.105z%22/%3E%3Cpath%20fill=%22%23ea4335%22%20d=%22M61.053%2080%2080%2061.053H61.053z%22/%3E%3Cpath%20fill=%22%23fbbc04%22%20d=%22M80%2018.947H61.053v42.105H80z%22/%3E%3Cpath%20fill=%22%2334a853%22%20d=%22M61.052%2061.053H18.947V80h42.105z%22/%3E%3Cpath%20fill=%22%23188038%22%20d=%22M0%2061.053v12.632A6.314%206.314%200%200%200%206.316%2080h12.632V61.053z%22/%3E%3Cpath%20fill=%22%231967d2%22%20d=%22M80%2018.947V6.316A6.314%206.314%200%200%200%2073.685%200H61.053v18.947z%22/%3E%3Cpath%20fill=%22%234285f4%22%20d=%22M61.053%200H6.316A6.314%206.314%200%200%200%200%206.316v54.737h18.947V18.947h42.105V0zM27.584%2051.611c-1.574-1.063-2.663-2.616-3.258-4.668l3.653-1.505q.498%201.894%201.737%202.937c1.239%201.043%201.821%201.037%202.989%201.037q1.792%200%203.079-1.089c1.287-1.089%201.29-1.653%201.29-2.774a3.44%203.44%200%200%200-1.358-2.811c-.905-.727-2.042-1.089-3.4-1.089h-2.111v-3.616H32.1q1.752%200%202.953-.947c1.201-.947%201.2-1.495%201.2-2.595q0-1.467-1.074-2.342c-1.074-.875-1.621-.879-2.721-.879q-1.61-.002-2.558.858c-.948.86-1.106%201.301-1.379%202.111l-3.616-1.505c.479-1.358%201.358-2.558%202.647-3.595s2.937-1.558%204.937-1.558q2.22-.002%203.989.858c1.769.86%202.105%201.368%202.774%202.379s1%202.153%201%203.416q0%201.932-.932%203.274c-.932%201.342-1.384%201.579-2.289%202.058v.216a6.95%206.95%200%200%201%202.937%202.289q1.146%201.538%201.147%203.684c.001%202.146-.363%202.711-1.089%203.832s-1.732%202.005-3.005%202.647c-1.279.642-2.716.968-4.311.968-1.847.005-3.553-.526-5.126-1.589zm22.437-18.126-4.01%202.9-2.005-3.042%207.195-5.189h2.758v24.479h-3.937V33.484z%22/%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E';
export const processInvoicesWorkflow: PreviewWorkflow = {
nodes: [
{
id: 'gmail-trigger',
label: 'Invoice received',
icon: { type: 'file', src: GMAIL_ICON_SVG },
position: { x: 0, y: 120 },
},
{
id: 'claude',
label: 'Extract & cross-check',
icon: { type: 'file', src: ANTHROPIC_ICON_SVG },
position: { x: 240, y: 120 },
},
{
id: 'if-discrepancy',
label: 'Discrepancy?',
icon: { type: 'icon', name: 'node:if' },
iconColor: 'green',
position: { x: 480, y: 120 },
},
{
id: 'flag-invoice',
label: 'Flag invoice',
icon: { type: 'file', src: GOOGLE_SHEETS_ICON_SVG },
position: { x: 720, y: 0 },
},
{
id: 'add-calendar',
label: 'Add to Calendar',
icon: { type: 'file', src: GOOGLE_CALENDAR_ICON_SVG },
position: { x: 720, y: 240 },
},
],
connections: [
{ source: 'gmail-trigger', target: 'claude' },
{ source: 'claude', target: 'if-discrepancy' },
{ source: 'if-discrepancy', target: 'flag-invoice' },
{ source: 'if-discrepancy', target: 'add-calendar' },
],
inputVisualization: {
type: 'salesforce-card',
props: {
icon: GMAIL_ICON_SVG,
title: 'Invoice received',
subtitle: 'Subject: INV-2024-047',
},
},
outputVisualization: [
{
type: 'invoice-spreadsheet',
targetNodeId: 'flag-invoice',
props: {},
},
{
type: 'salesforce-card',
targetNodeId: 'add-calendar',
props: {
icon: GOOGLE_CALENDAR_ICON_SVG,
title: 'Event created',
subtitle: 'Payment due · Jun 15',
},
},
],
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,54 @@
export interface PreviewWorkflowNodeIcon {
type: 'icon' | 'file';
name?: string;
src?: string;
}
export interface PreviewWorkflowNode {
id: string;
label: string;
icon: PreviewWorkflowNodeIcon;
iconColor?: string;
position: { x: number; y: number };
}
export interface PreviewWorkflowConnection {
source: string;
target: string;
}
export type PreviewVisualizationType =
| 'slack-message'
| 'salesforce-card'
| 'invoice-spreadsheet'
| 'whatsapp-chat';
export interface PreviewVisualization {
type: PreviewVisualizationType;
props?: Record<string, unknown>;
}
export interface PreviewOutputVisualization {
type: PreviewVisualizationType;
props?: Record<string, unknown>;
targetNodeId: string;
}
export interface CrmCycleVariant {
icon: PreviewWorkflowNodeIcon;
label: string;
}
export interface CrmCycleConfig {
nodeIds: string[];
variants: CrmCycleVariant[];
intervalMs?: number;
}
export interface PreviewWorkflow {
nodes: PreviewWorkflowNode[];
connections: PreviewWorkflowConnection[];
inputVisualization?: PreviewVisualization;
outputVisualization?: PreviewVisualization | PreviewOutputVisualization[];
crmCycle?: CrmCycleConfig;
}

View File

@ -23,6 +23,14 @@ import {
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION,
useInstanceAiPromptSuggestionsV2Experiment,
} from '@/experiments/instanceAiPromptSuggestionsV2';
import {
WorkflowPreviewSuggestions,
WorkflowPreviewCanvas,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
getPreviewWorkflow,
useInstanceAiWorkflowPreviewSuggestionsExperiment,
} from '@/experiments/instanceAiWorkflowPreviewSuggestions';
import InstanceAiInput from './components/InstanceAiInput.vue';
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
@ -34,6 +42,10 @@ const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY: BaseTextKey =
'experiments.instanceAiPromptSuggestionsV2.emptyState.title';
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey =
'experiments.instanceAiPromptSuggestionsV2.input.placeholder';
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY =
'experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title' as BaseTextKey;
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY =
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder' as BaseTextKey;
const store = useInstanceAiStore();
const { isLowCredits } = storeToRefs(store);
@ -46,7 +58,15 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
useInstanceAiProactiveAgentExperiment();
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
useInstanceAiPromptSuggestionsV2Experiment();
const { isFeatureEnabled: isWorkflowPreviewSuggestionsExperimentEnabled } =
useInstanceAiWorkflowPreviewSuggestionsExperiment();
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
const activeWorkflowPreviewFile = ref<string | null>(null);
const activeWorkflowPreview = computed(() => {
if (!activeWorkflowPreviewFile.value) return null;
return getPreviewWorkflow(activeWorkflowPreviewFile.value) ?? null;
});
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
const emptyStatePromptSuggestionProps = computed(() => {
if (showProactiveStarter.value) {
@ -62,20 +82,36 @@ const emptyStatePromptSuggestionProps = computed(() => {
};
}
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
return {
suggestions: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
suggestionsComponent: WorkflowPreviewSuggestions,
suggestionCatalogVersion: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY,
};
}
return {
suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS,
};
});
const emptyStateTitleKey = computed<BaseTextKey>(() =>
isPromptSuggestionsV2ExperimentEnabled.value
? INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY
: INSTANCE_AI_DEFAULT_TITLE_KEY,
);
const emptyStateTitleKey = computed<BaseTextKey>(() => {
if (isPromptSuggestionsV2ExperimentEnabled.value) {
return INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY;
}
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
return INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY;
}
return INSTANCE_AI_DEFAULT_TITLE_KEY;
});
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
const isStartingThread = ref(false);
useChatInputAutoFocus(chatInputRef, { disabled: isStartingThread });
function handleWorkflowPreview(workflowFile: string | null) {
activeWorkflowPreviewFile.value = workflowFile;
}
onMounted(() => {
void nextTick(() => chatInputRef.value?.focus());
@ -144,8 +180,16 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
:is-submitting="isStartingThread"
v-bind="emptyStatePromptSuggestionProps"
@submit="handleSubmit"
@workflow-preview="handleWorkflowPreview"
/>
</div>
<Transition name="workflow-preview-fade">
<WorkflowPreviewCanvas
v-if="isWorkflowPreviewSuggestionsExperimentEnabled && activeWorkflowPreview"
:workflow="activeWorkflowPreview"
:class="$style.workflowPreview"
/>
</Transition>
</div>
</div>
</div>
@ -184,6 +228,11 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
max-width: 680px;
}
.workflowPreview {
width: 100%;
max-width: 1600px;
}
.proactiveLayout {
flex: 1;
display: flex;
@ -208,4 +257,26 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
margin: 0 auto;
padding: 0 var(--spacing--lg) var(--spacing--sm);
}
:global(.workflow-preview-fade-enter-active) {
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
:global(.workflow-preview-fade-leave-active) {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
:global(.workflow-preview-fade-enter-from) {
opacity: 0;
transform: translateY(8px);
}
:global(.workflow-preview-fade-leave-to) {
opacity: 0;
transform: translateY(4px);
}
</style>

View File

@ -15,12 +15,15 @@ const {
experimentMocks,
promptSuggestionsV2,
promptSuggestionsV2Component,
workflowPreviewSuggestions,
workflowPreviewSuggestionsComponent,
replaceMock,
showErrorMock,
} = vi.hoisted(() => ({
experimentMocks: {
proactiveAgentEnabled: { value: false },
promptSuggestionsV2Enabled: { value: false },
workflowPreviewEnabled: { value: false },
},
promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({
type: 'prompt',
@ -30,6 +33,14 @@ const {
promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt',
})),
promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' },
workflowPreviewSuggestions: Array.from({ length: 4 }, (_, index) => ({
type: 'prompt',
id: `wp-suggestion-${index + 1}`,
icon: 'workflow',
labelKey: 'instanceAi.emptyState.suggestions.buildWorkflow.label',
promptKey: 'instanceAi.emptyState.suggestions.buildWorkflow.prompt',
})),
workflowPreviewSuggestionsComponent: { name: 'WorkflowPreviewSuggestionsStub' },
replaceMock: vi.fn(),
showErrorMock: vi.fn(),
}));
@ -53,6 +64,17 @@ vi.mock('@/experiments/instanceAiPromptSuggestionsV2', () => ({
InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component,
}));
vi.mock('@/experiments/instanceAiWorkflowPreviewSuggestions', () => ({
useInstanceAiWorkflowPreviewSuggestionsExperiment: () => ({
isFeatureEnabled: experimentMocks.workflowPreviewEnabled,
}),
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: workflowPreviewSuggestions,
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION: 'v3-workflow-preview',
WorkflowPreviewSuggestions: workflowPreviewSuggestionsComponent,
WorkflowPreviewCanvas: { name: 'WorkflowPreviewCanvasStub', template: '<div />' },
getPreviewWorkflow: () => null,
}));
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
}));
@ -160,6 +182,7 @@ describe('InstanceAiEmptyView', () => {
store.getOrCreateRuntime.mockReturnValue(thread);
experimentMocks.proactiveAgentEnabled.value = false;
experimentMocks.promptSuggestionsV2Enabled.value = false;
experimentMocks.workflowPreviewEnabled.value = false;
});
afterEach(() => {
@ -192,6 +215,22 @@ describe('InstanceAiEmptyView', () => {
);
});
it('passes workflow preview suggestions, component, and catalog version when workflow preview experiment is enabled', () => {
experimentMocks.workflowPreviewEnabled.value = true;
const { getByTestId, getByText } = renderView();
expect(getByText('What do you want to automate?')).toBeVisible();
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('4');
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('set');
expect(getByTestId('instance-ai-input-suggestion-catalog-version')).toHaveTextContent(
'v3-workflow-preview',
);
expect(getByTestId('instance-ai-input-placeholder-key')).toHaveTextContent(
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder',
);
});
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
experimentMocks.proactiveAgentEnabled.value = true;
experimentMocks.promptSuggestionsV2Enabled.value = true;

View File

@ -61,6 +61,7 @@ const emit = defineEmits<{
submit: [message: string, attachments?: InstanceAiAttachment[]];
stop: [];
'cancel-plan-edit': [];
'workflow-preview': [workflowFile: string | null];
}>();
const i18n = useI18n();
@ -144,6 +145,7 @@ watch(
}
previewPromptKey.value = null;
emit('workflow-preview', null);
},
{ immediate: true },
);
@ -381,6 +383,7 @@ const resizable = computed(() => {
@cycle-suggestions="handleSuggestionsCycled"
@insert-suggestion="handleSuggestionInsert"
@submit-suggestion="handleSuggestionSubmit"
@workflow-preview="emit('workflow-preview', $event)"
/>
</Transition>
</div>