fix(editor): Polish Instance AI visuals (no-changelog) (#30987)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.22.3) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.15.0) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run

This commit is contained in:
Tuukka Kantola 2026-05-25 13:30:27 +02:00 committed by GitHub
parent 40ecd5ea33
commit ca949e15c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 184 additions and 58 deletions

View File

@ -428,20 +428,52 @@ watch(
// --- Floating input dynamic padding ---
const inputContainerRef = useTemplateRef<HTMLElement>('inputContainer');
const inputSwapRef = useTemplateRef<HTMLElement>('inputSwap');
const inputAreaHeight = ref(120);
let resizeObserver: ResizeObserver | null = null;
const scrollButtonBottomOffset = ref(144);
let inputContainerResizeObserver: ResizeObserver | null = null;
let inputSwapResizeObserver: ResizeObserver | null = null;
function updateScrollButtonBottomOffset() {
const container = inputContainerRef.value;
const inputSwap = inputSwapRef.value;
if (!container || !inputSwap) {
scrollButtonBottomOffset.value = inputAreaHeight.value + 24;
return;
}
const containerBottom = container.getBoundingClientRect().bottom;
const inputSwapTop = inputSwap.getBoundingClientRect().top;
scrollButtonBottomOffset.value = Math.max(24, containerBottom - inputSwapTop + 24);
}
watch(
inputContainerRef,
(el) => {
resizeObserver?.disconnect();
inputContainerResizeObserver?.disconnect();
if (el) {
resizeObserver = new ResizeObserver((entries) => {
inputContainerResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
inputAreaHeight.value = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height;
}
updateScrollButtonBottomOffset();
});
resizeObserver.observe(el);
inputContainerResizeObserver.observe(el);
}
},
{ immediate: true },
);
watch(
inputSwapRef,
(el) => {
inputSwapResizeObserver?.disconnect();
if (el) {
inputSwapResizeObserver = new ResizeObserver(() => {
updateScrollButtonBottomOffset();
});
inputSwapResizeObserver.observe(el);
updateScrollButtonBottomOffset();
}
},
{ immediate: true },
@ -483,7 +515,8 @@ onMounted(() => {
onUnmounted(() => {
thread.closeSSE();
contentResizeObserver?.disconnect();
resizeObserver?.disconnect();
inputContainerResizeObserver?.disconnect();
inputSwapResizeObserver?.disconnect();
executionTracking.cleanup();
});
@ -645,13 +678,15 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
<!-- Scroll to bottom button -->
<div
:class="$style.scrollButtonContainer"
:style="{ bottom: `${inputAreaHeight + 8}px` }"
:style="{ bottom: `${scrollButtonBottomOffset}px` }"
>
<Transition name="fade">
<Transition name="scroll-button-fade">
<N8nIconButton
v-if="userScrolledUp && thread.hasMessages"
variant="outline"
icon="arrow-down"
size="large"
icon-size="large"
:class="$style.scrollToBottomButton"
@click="
scrollToBottom(true);
@ -677,7 +712,7 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
@upgrade-click="goToUpgrade('instance-ai', 'upgrade-instance-ai')"
@dismiss="creditBanner.dismiss()"
/>
<div :class="$style.inputSwap">
<div ref="inputSwap" :class="$style.inputSwap">
<Transition name="input-swap">
<InstanceAiConfirmationPanel
v-if="hasFloatingConfirmation"
@ -1002,14 +1037,34 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
}
.scrollToBottomButton {
pointer-events: auto;
background: var(--color--background--light-2);
border: var(--border);
border-radius: var(--radius);
color: var(--color--text--tint-1);
--button--color: var(--icon-color--strong);
--button--color--background: var(--background--surface);
--button--color--background-hover: var(--color--foreground--tint-2);
--button--color--background-active: var(--color--foreground--tint-2);
--button--shadow: var(--shadow--xs);
--button--shadow--hover: var(--shadow--xs);
--button--shadow--active: var(--shadow--xs);
--button--border-color: var(--border-color);
--button--border-color--hover: var(--border-color);
--button--border-color--active: var(--border-color);
--button--border--shadow: 0 0 0 1px var(--button--border-color);
--button--border--shadow--hover: 0 0 0 1px var(--button--border-color--hover);
--button--border--shadow--active: 0 0 0 1px var(--button--border-color--active);
--button--radius: var(--radius--full);
&:hover {
background: var(--color--foreground--tint-2);
pointer-events: auto;
&.scrollToBottomButton {
background-color: var(--background--surface);
border: var(--border);
border-radius: var(--radius--full);
box-shadow: var(--shadow--xs);
color: var(--icon-color--strong);
&:hover {
background-color: var(--color--foreground--tint-2);
box-shadow: var(--shadow--xs);
}
}
}
@ -1096,6 +1151,16 @@ function handleWorkflowFailures(report: WorkflowFailuresReport) {
transition: opacity 0.2s ease;
}
.scroll-button-fade-enter-from,
.scroll-button-fade-leave-to {
opacity: 0;
}
.scroll-button-fade-enter-active,
.scroll-button-fade-leave-active {
transition: opacity 0.12s ease;
}
.preview-panel-slide-enter-active,
.preview-panel-slide-leave-active {
--preview-panel-slide-easing: var(--easing--ease-in-out);

View File

@ -109,6 +109,11 @@ function resolveArtifactName(artifact: ArtifactInfo): string {
<style lang="scss" module>
.reasoningTrigger {
--button--padding: 0;
--button--font-size: var(--font-size--sm);
padding-inline: 0;
font-size: var(--font-size--sm);
color: var(--color--text--tint-2);
}

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nCallout, N8nIcon } from '@n8n/design-system';
import { N8nCallout } from '@n8n/design-system';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed, ref, watch } from 'vue';
import SubagentStepTimeline from './SubagentStepTimeline.vue';
import TimelineStepChevron from './TimelineStepChevron.vue';
import TimelineStepButton from './TimelineStepButton.vue';
import { useSettingsStore } from '@/app/stores/settings.store';
@ -51,7 +52,7 @@ watch(
<CollapsibleTrigger as-child>
<TimelineStepButton :loading="isActive" size="medium">
<template #icon>
<N8nIcon :icon="isOpen ? 'chevron-down' : 'chevron-right'" size="small" />
<TimelineStepChevron :open="isOpen" />
</template>
{{ sectionTitle }}
</TimelineStepButton>

View File

@ -69,7 +69,7 @@ function getAnswers(): DisplayAnswer[] {
}
.userBubble {
background: var(--color--background);
background: var(--assistant--color--background--user-bubble);
padding: var(--spacing--xs) var(--spacing--sm) var(--spacing--sm);
border-radius: var(--radius--xl);
white-space: pre-wrap;
@ -92,7 +92,7 @@ function getAnswers(): DisplayAnswer[] {
}
.skipped {
color: var(--color--text--tint-2);
color: var(--text-color);
font-style: italic;
}
</style>

View File

@ -61,7 +61,7 @@ function onKeydown(event: KeyboardEvent) {
</script>
<template>
<ConfirmationFooter layout="column">
<ConfirmationFooter layout="column" :class="$style.footer">
<div
ref="container"
:class="$style.list"
@ -89,16 +89,16 @@ function onKeydown(event: KeyboardEvent) {
@click="emit('select', option.key)"
@mouseenter="highlightedIndex = idx"
>
<N8nIcon :class="$style.leadingIcon" :icon="option.icon" size="small" />
<N8nIcon :class="$style.leadingIcon" :icon="option.icon" size="large" />
<span :class="$style.label">
<span :class="option.suffix ? $style.labelStrong : undefined">{{ option.label }}</span>
<span :class="$style.labelStrong">{{ option.label }}</span>
<span v-if="option.suffix" :class="$style.labelMuted">{{ option.suffix }}</span>
</span>
<N8nIcon
v-if="option.withArrow !== false"
:class="$style.trailingIcon"
icon="arrow-right"
size="small"
size="large"
/>
</button>
</div>
@ -112,20 +112,24 @@ function onKeydown(event: KeyboardEvent) {
outline: none;
}
.footer {
padding: 0;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
width: 100%;
padding: var(--spacing--3xs) var(--spacing--2xs);
min-height: 36px;
padding: var(--spacing--3xs) var(--spacing--2xs) var(--spacing--3xs) var(--spacing--xs);
border: none;
border-radius: var(--radius--lg);
background: none;
cursor: pointer;
text-align: left;
font-size: var(--font-size--sm);
color: var(--color--text);
transition: background-color 0.15s ease;
color: var(--text-color);
}
// Highlight: applied when the row is the current selection (keyboard or
@ -135,24 +139,29 @@ function onKeydown(event: KeyboardEvent) {
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
.trailingIcon {
opacity: 1;
visibility: visible;
}
}
// Destructive variant only changes the highlight colour, so the cost of
// confirming becomes obvious the moment the user lands on the row.
.rowDestructive.highlighted {
background-color: var(--callout--color--background--danger);
color: var(--callout--color--text--danger);
background-color: light-dark(var(--color--red-100), var(--callout--color--background--danger));
color: light-dark(var(--color--red-800), var(--color--red-250));
.leadingIcon {
color: var(--callout--color--text--danger);
color: light-dark(var(--color--red-800), var(--color--red-250));
}
.trailingIcon {
color: light-dark(var(--color--red-800), var(--color--red-250));
opacity: 0.6;
}
}
.leadingIcon {
flex-shrink: 0;
color: var(--color--text--tint-1);
color: var(--icon-color--strong);
}
.label {
@ -165,19 +174,19 @@ function onKeydown(event: KeyboardEvent) {
}
.labelStrong {
font-weight: var(--font-weight--bold);
font-weight: var(--font-weight--medium);
}
.labelMuted {
color: var(--color--text--tint-1);
color: var(--text-color--subtle);
font-weight: var(--font-weight--regular);
}
.trailingIcon {
margin-left: auto;
opacity: 0;
color: var(--color--text--tint-1);
visibility: hidden;
color: var(--icon--color);
opacity: 0.7;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
</style>

View File

@ -10,8 +10,7 @@
font-size: var(--font-size--sm);
color: var(--color--text);
word-break: break-all;
padding: var(--spacing--2xs);
padding: var(--spacing--2xs) 0;
border-radius: var(--radius);
border: var(--border);
}
</style>

View File

@ -9,11 +9,15 @@
font-size: var(--font-size--sm);
color: var(--color--text--tint-2);
background: var(--color--foreground--tint-2);
border-radius: var(--radius);
border-radius: var(--radius--xs);
padding: var(--spacing--2xs);
margin-top: var(--spacing--2xs);
border: var(--border);
max-width: 90%;
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: light-dark(var(--color--neutral-300), var(--color--neutral-700)) transparent;
:global(pre) {
background: transparent;

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import { N8nBadge, N8nCard, N8nIcon, N8nText } from '@n8n/design-system';
import { N8nBadge, N8nCard, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { computed } from 'vue';
import { useToolLabel } from '../toolLabels';
import TimelineStepChevron from './TimelineStepChevron.vue';
import TimelineStepButton from './TimelineStepButton.vue';
const props = defineProps<{
@ -36,7 +37,7 @@ const briefing = computed(() => {
<CollapsibleTrigger as-child>
<TimelineStepButton :loading="props.isLoading" size="medium">
<template #icon>
<N8nIcon :icon="isOpen ? 'chevron-down' : 'chevron-right'" size="small" />
<TimelineStepChevron :open="isOpen" />
</template>
{{ i18n.baseText('instanceAi.delegateCard.delegatingTo') }}:
<N8nText bold>{{ role }}</N8nText>

View File

@ -168,7 +168,7 @@ function buildApprovalOptions(item: PendingConfirmationItem): ApprovalOption[] {
if (!destructive) {
options.push({
key: 'always-allow',
icon: 'check',
icon: 'check-check',
label: i18n.baseText('instanceAi.confirmation.alwaysAllow'),
suffix: i18n.baseText('instanceAi.confirmation.alwaysAllowSuffix'),
testId: 'instance-ai-panel-confirm-always-allow',
@ -185,7 +185,6 @@ function buildApprovalOptions(item: PendingConfirmationItem): ApprovalOption[] {
key: 'deny',
icon: 'ban',
label: i18n.baseText('instanceAi.confirmation.deny'),
withArrow: false,
testId: 'instance-ai-panel-confirm-deny',
});
return options;
@ -563,7 +562,7 @@ function handlePlanRequestChanges(
<div v-else>
<div :class="$style.approvalRow">
<div :class="$style.approvalRowBody">
<N8nText size="medium" bold>
<N8nText size="large" bold>
{{ buildApprovalTitle(chunk.item) }}
</N8nText>
<ConfirmationPreview>{{ buildApprovalSubtitle(chunk.item) }}</ConfirmationPreview>
@ -589,9 +588,12 @@ function handlePlanRequestChanges(
}
.root {
border: var(--border);
border-radius: var(--radius--lg);
background-color: var(--color--background--light-3);
border: none;
border-radius: var(--radius--xl);
box-shadow:
var(--shadow--xs),
inset 0 0 0 1px light-dark(var(--color--black-alpha-100), var(--color--white-alpha-100));
background-color: var(--background--surface);
}
.floatingRoot {
@ -615,12 +617,12 @@ function handlePlanRequestChanges(
.approvalRow {
display: flex;
flex-direction: column;
padding: var(--spacing--4xs) 0;
gap: var(--spacing--2xs);
padding: var(--spacing--sm);
font-size: var(--font-size--2xs);
}
.approvalRowBody {
padding: var(--spacing--sm) var(--spacing--sm) 0;
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);

View File

@ -183,6 +183,7 @@ function formatJson(value: unknown): string {
display: flex;
justify-content: flex-end;
width: 100%;
margin-block: var(--spacing--md);
}
.userAttachments {
@ -193,7 +194,7 @@ function formatJson(value: unknown): string {
}
.userBubble {
background: var(--color--background);
background: var(--assistant--color--background--user-bubble);
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius--xl);
white-space: pre-wrap;

View File

@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
@ -8,6 +7,7 @@ import { computed } from 'vue';
import type { ResponseGroupSegment } from '../useTimelineGrouping';
import AgentTimeline from './AgentTimeline.vue';
import TimelineStepButton from './TimelineStepButton.vue';
import TimelineStepChevron from './TimelineStepChevron.vue';
const props = defineProps<{
group: ResponseGroupSegment;
@ -89,7 +89,7 @@ const isCollapsible = computed(
<CollapsibleTrigger as-child>
<TimelineStepButton size="medium">
<template #icon>
<N8nIcon :icon="isOpen ? 'chevron-down' : 'chevron-right'" size="small" />
<TimelineStepChevron :open="isOpen" />
</template>
{{ summaryText }}
</TimelineStepButton>

View File

@ -122,7 +122,7 @@ const steps = computed((): TimelineStep[] => {
<template v-else-if="step.type === 'text'">
<CollapsibleRoot v-if="step.isLongText" v-slot="{ open }">
<CollapsibleTrigger as-child>
<N8nButton ref="triggerRef" variant="ghost" size="small">
<N8nButton ref="triggerRef" variant="ghost" size="small" :class="$style.toggleTrigger">
<template #icon>
<template v-if="step.isLoading">
<N8nIcon icon="spinner" size="small" color="primary" spin />
@ -166,6 +166,14 @@ const steps = computed((): TimelineStep[] => {
overflow-y: auto;
}
.toggleTrigger {
--button--padding: 0;
--button--font-size: var(--font-size--sm);
padding-inline: 0;
font-size: var(--font-size--sm);
}
.streamingMarkdown {
display: flex;
flex-direction: column-reverse;

View File

@ -38,8 +38,12 @@ defineSlots<{
max-width: 90%;
justify-content: flex-start;
color: var(--color--text--tint-1);
font-size: var(--font-size--sm);
position: relative;
padding-inline: 0;
--button--padding: 0;
--button--font-size: var(--font-size--sm);
--button--color--background-active: transparent;
--button--color--background-hover: transparent;
&:hover {

View File

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { N8nIcon } from '@n8n/design-system';
defineProps<{
open: boolean;
}>();
</script>
<template>
<N8nIcon icon="chevron-right" size="large" :class="[$style.chevron, open && $style.open]" />
</template>
<style lang="scss" module>
.chevron {
transition: transform var(--duration--snappy) var(--easing--ease-out);
transform-origin: center;
}
.open {
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.chevron {
transition: none;
}
}
</style>

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { InstanceAiToolCallState } from '@n8n/api-types';
import { N8nCallout, N8nIcon } from '@n8n/design-system';
import { N8nCallout } from '@n8n/design-system';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import AnimatedCollapsibleContent from './AnimatedCollapsibleContent.vue';
import { useToolLabel } from '../toolLabels';
import DataSection from './DataSection.vue';
import TimelineStepChevron from './TimelineStepChevron.vue';
import TimelineStepButton from './TimelineStepButton.vue';
import ToolResultJson from './ToolResultJson.vue';
import ToolResultRenderer from './ToolResultRenderer.vue';
@ -50,7 +51,7 @@ function getDisplayLabel(tc: InstanceAiToolCallState): string {
<CollapsibleTrigger as-child>
<TimelineStepButton :loading="props.toolCall.isLoading">
<template #icon>
<N8nIcon :icon="isOpen ? 'chevron-down' : 'chevron-right'" size="small" />
<TimelineStepChevron :open="isOpen" />
</template>
{{ props.label ?? getDisplayLabel(props.toolCall) }}
</TimelineStepButton>

View File

@ -100,13 +100,11 @@ function downloadFullJson() {
<style lang="scss" module>
.json {
font-family: monospace;
font-size: var(--font-size--sm);
font-size: var(--font-size--xs);
line-height: var(--line-height--xl);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
max-height: 200px;
overflow-y: auto;
color: var(--color--text--tint-1);
}