refactor(editor): Centralize instance AI confirmation dialog styling (#28195)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Tuukka Kantola
2026-04-09 07:25:22 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a95cbfb429
commit cc32c507c5
10 changed files with 253 additions and 279 deletions
@@ -0,0 +1,44 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
layout?: 'row-end' | 'row-between' | 'column';
bordered?: boolean;
}>(),
{
layout: 'row-end',
bordered: false,
},
);
</script>
<template>
<div :class="[$style.footer, $style[layout], bordered && $style.bordered]">
<slot />
</div>
</template>
<style lang="scss" module>
.footer {
display: flex;
padding: var(--spacing--xs) var(--spacing--sm);
}
.row-end {
gap: var(--spacing--2xs);
justify-content: flex-end;
}
.row-between {
justify-content: space-between;
align-items: center;
}
.column {
flex-direction: column;
gap: var(--spacing--2xs);
}
.bordered {
border-top: var(--border);
}
</style>
@@ -0,0 +1,18 @@
<template>
<div :class="$style.preview">
<slot />
</div>
</template>
<style lang="scss" module>
.preview {
font-family: monospace;
font-size: var(--font-size--sm);
color: var(--color--text);
word-break: break-all;
padding: var(--spacing--2xs);
background: var(--color--background);
border-radius: var(--radius);
border: var(--border);
}
</style>
@@ -1,9 +1,12 @@
<script lang="ts" setup>
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import { N8nButton, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue';
import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue';
type DomainAction = 'allow_once' | 'allow_domain' | 'allow_all';
@@ -64,7 +67,7 @@ function onPrimaryClick() {
handleAction(true, primaryAction.value);
}
function onDropdownSelect(action: DomainAction) {
function onDropdownSelect(action: string) {
handleAction(true, action);
}
</script>
@@ -72,15 +75,15 @@ function onDropdownSelect(action: DomainAction) {
<template>
<div v-if="!resolved">
<div :class="$style.body">
<div :class="$style.message">
<span>{{
<N8nText tag="div" size="medium" bold>
{{
i18n.baseText('instanceAi.domainAccess.prompt', { interpolate: { domain: props.host } })
}}</span>
</div>
<div :class="$style.urlPreview">{{ props.url }}</div>
}}
</N8nText>
<ConfirmationPreview>{{ props.url }}</ConfirmationPreview>
</div>
<div :class="$style.actions">
<ConfirmationFooter>
<N8nButton
variant="outline"
size="small"
@@ -88,92 +91,25 @@ function onDropdownSelect(action: DomainAction) {
data-test-id="domain-access-deny"
@click="handleAction(false)"
/>
<div :class="$style.splitButton">
<N8nButton
:variant="isDestructive ? 'destructive' : 'solid'"
:class="$style.splitButtonMain"
:label="primaryLabel"
data-test-id="domain-access-primary"
size="small"
@click="onPrimaryClick"
/>
<N8nActionDropdown
:items="dropdownItems"
:class="$style.splitButtonDropdown"
data-test-id="domain-access-dropdown"
placement="bottom-start"
@select="onDropdownSelect"
>
<template #activator>
<N8nIconButton
:variant="isDestructive ? 'destructive' : 'solid'"
icon="chevron-down"
:class="$style.splitButtonCaret"
aria-label="More approval options"
size="small"
/>
</template>
</N8nActionDropdown>
</div>
</div>
<SplitButton
:variant="isDestructive ? 'destructive' : 'solid'"
:label="primaryLabel"
:items="dropdownItems"
data-test-id="domain-access-primary"
dropdown-test-id="domain-access-dropdown"
caret-aria-label="More approval options"
@click="onPrimaryClick"
@select="onDropdownSelect"
/>
</ConfirmationFooter>
</div>
</template>
<style lang="scss" module>
.message {
display: flex;
align-items: flex-start;
gap: var(--spacing--3xs);
font-size: var(--font-size--2xs);
color: var(--color--text);
margin-bottom: var(--spacing--xs);
font-weight: var(--font-weight--medium);
}
.urlPreview {
font-family: monospace;
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
word-break: break-all;
margin-bottom: var(--spacing--xs);
padding: var(--spacing--2xs);
background: var(--color--background);
border-radius: var(--radius);
border: var(--border);
}
.body {
padding: var(--spacing--sm) var(--spacing--sm);
padding: var(--spacing--sm) var(--spacing--sm) 0;
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.actions {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.splitButton {
display: flex;
position: relative;
}
.splitButtonMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.splitButtonDropdown {
display: flex;
}
.splitButtonCaret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid var(--color--foreground--tint-2);
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import { N8nButton, N8nText } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
@@ -7,6 +7,9 @@ import { computed } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiStore } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue';
import ConfirmationPreview from './ConfirmationPreview.vue';
import SplitButton from './SplitButton.vue';
const props = defineProps<{
requestId: string;
@@ -90,17 +93,17 @@ async function confirm(decision: string) {
<template>
<div :class="$style.root">
<div :class="$style.body">
<div :class="$style.message">
<N8nText tag="div" size="medium" bold>
{{
i18n.baseText('instanceAi.gatewayConfirmation.prompt', {
interpolate: { resources: props.resource },
})
}}
</div>
<div :class="$style.preview">{{ props.description }}</div>
</N8nText>
<ConfirmationPreview>{{ props.description }}</ConfirmationPreview>
</div>
<div :class="$style.actions">
<ConfirmationFooter>
<!-- Unknown options not in the standard set -->
<N8nButton
v-for="opt in otherOptions"
@@ -113,80 +116,30 @@ async function confirm(decision: string) {
<!-- Deny side -->
<template v-if="denyPrimary">
<div v-if="denyDropdownItems.length" :class="$style.splitButton">
<N8nButton
variant="outline"
size="small"
:label="denyPrimary.label"
:class="$style.splitButtonMain"
data-test-id="gateway-decision-deny"
@click="confirm(denyPrimary.decision)"
/>
<N8nActionDropdown
:items="denyDropdownItems"
:class="$style.splitButtonDropdown"
placement="bottom-start"
@select="confirm"
>
<template #activator>
<N8nIconButton
variant="outline"
icon="chevron-down"
:class="$style.splitButtonCaret"
aria-label="More deny options"
size="small"
/>
</template>
</N8nActionDropdown>
</div>
<N8nButton
v-else
<SplitButton
variant="outline"
size="small"
:label="denyPrimary.label"
:items="denyDropdownItems"
data-test-id="gateway-decision-deny"
caret-aria-label="More deny options"
@click="confirm(denyPrimary.decision)"
@select="confirm"
/>
</template>
<!-- Approve side -->
<template v-if="approvePrimary">
<div v-if="approveDropdownItems.length" :class="$style.splitButton">
<N8nButton
variant="solid"
size="small"
:label="approvePrimary.label"
:class="$style.splitButtonMain"
data-test-id="gateway-decision-approve"
@click="confirm(approvePrimary.decision)"
/>
<N8nActionDropdown
:items="approveDropdownItems"
:class="$style.splitButtonDropdown"
placement="bottom-start"
@select="confirm"
>
<template #activator>
<N8nIconButton
variant="solid"
icon="chevron-down"
:class="$style.splitButtonCaret"
aria-label="More approve options"
size="small"
/>
</template>
</N8nActionDropdown>
</div>
<N8nButton
v-else
<SplitButton
variant="solid"
size="small"
:label="approvePrimary.label"
:items="approveDropdownItems"
data-test-id="gateway-decision-approve"
caret-aria-label="More approve options"
@click="confirm(approvePrimary.decision)"
@select="confirm"
/>
</template>
</div>
</ConfirmationFooter>
</div>
</template>
@@ -197,54 +150,9 @@ async function confirm(decision: string) {
}
.body {
padding: var(--spacing--sm);
padding: var(--spacing--sm) var(--spacing--sm) 0;
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.message {
font-size: var(--font-size--2xs);
color: var(--color--text);
font-weight: var(--font-weight--medium);
}
.preview {
font-family: monospace;
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
word-break: break-all;
padding: var(--spacing--2xs);
background: var(--color--background);
border-radius: var(--radius);
border: var(--border);
}
.actions {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.splitButton {
display: flex;
position: relative;
}
.splitButtonMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.splitButtonDropdown {
display: flex;
}
.splitButtonCaret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid var(--color--foreground--tint-2);
}
</style>
@@ -7,12 +7,14 @@ import { computed, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useInstanceAiStore, type PendingConfirmationItem } from '../instanceAi.store';
import { useToolLabel } from '../toolLabels';
import ConfirmationFooter from './ConfirmationFooter.vue';
import DomainAccessApproval from './DomainAccessApproval.vue';
import GatewayResourceDecision from './GatewayResourceDecision.vue';
import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue';
import type { QuestionAnswer } from './InstanceAiQuestions.vue';
import InstanceAiQuestions from './InstanceAiQuestions.vue';
import InstanceAiWorkflowSetup from './InstanceAiWorkflowSetup.vue';
import ConfirmationPreview from './ConfirmationPreview.vue';
import PlanReviewPanel, { type PlannedTaskArg } from './PlanReviewPanel.vue';
const store = useInstanceAiStore();
@@ -423,6 +425,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
<N8nButton
data-test-id="instance-ai-panel-confirm-approve-all"
size="small"
variant="subtle"
@click="handleApproveAll(chunk.items)"
>
{{ i18n.baseText('instanceAi.confirmation.approveAll') }}
@@ -450,13 +453,15 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
<div v-else>
<div :class="$style.approvalRow">
<div :class="$style.approvalRowBody">
<span :class="$style.toolLabel">
<N8nText size="medium" bold>
{{ getToolLabel(item.toolCall.toolName) }}
</span>
<span :class="$style.preview">{{ item.toolCall.confirmation.message }}</span>
</N8nText>
<ConfirmationPreview>{{
item.toolCall.confirmation!.message
}}</ConfirmationPreview>
</div>
<div :class="$style.approvalActions">
<ConfirmationFooter>
<N8nButton
data-test-id="instance-ai-panel-confirm-deny"
size="small"
@@ -477,7 +482,7 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
>
{{ i18n.baseText('instanceAi.confirmation.approve') }}
</N8nButton>
</div>
</ConfirmationFooter>
</div>
</div>
</div>
@@ -516,47 +521,15 @@ function isAllGenericApproval(items: PendingConfirmationItem[]): boolean {
.approvalRow {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
padding: var(--spacing--4xs) 0;
font-size: var(--font-size--2xs);
}
.toolLabel {
display: inline-flex;
white-space: nowrap;
align-items: flex-start;
gap: var(--spacing--3xs);
font-size: var(--font-size--2xs);
color: var(--color--text);
margin-bottom: var(--spacing--xs);
font-weight: var(--font-weight--medium);
}
.approvalRowBody {
padding: var(--spacing--sm) var(--spacing--sm);
padding: var(--spacing--sm) var(--spacing--sm) 0;
display: flex;
flex-direction: column;
gap: var(--spacing--3xs);
}
.approvalActions {
display: flex;
gap: var(--spacing--2xs);
justify-content: flex-end;
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.preview {
font-family: monospace;
font-size: var(--font-size--3xs);
color: var(--color--text--tint-1);
word-break: break-all;
margin-bottom: var(--spacing--xs);
padding: var(--spacing--2xs);
background: var(--color--background);
border-radius: var(--radius);
border: var(--border);
}
.textInputRow {
@@ -13,6 +13,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useInstanceAiStore } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue';
const props = defineProps<{
requestId: string;
@@ -356,7 +357,7 @@ async function handleLater() {
</div>
<!-- Footer -->
<footer :class="$style.footer">
<ConfirmationFooter layout="row-between">
<div :class="$style.footerNav">
<N8nButton
v-if="showArrows"
@@ -411,7 +412,7 @@ async function handleLater() {
@click="handleContinue"
/>
</div>
</footer>
</ConfirmationFooter>
</div>
</template>
@@ -483,14 +484,6 @@ async function handleLater() {
}
}
.footer {
display: flex;
align-items: center;
gap: var(--spacing--xs);
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.footerNav {
display: flex;
flex: 1;
@@ -11,6 +11,7 @@
import { ref, computed, watch, nextTick } from 'vue';
import { N8nButton, N8nCheckbox, N8nIcon, N8nInput, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import ConfirmationFooter from './ConfirmationFooter.vue';
const OTHER_SENTINEL = '__other__';
@@ -509,7 +510,7 @@ function onOptionMouseEnter(idx: number) {
</Transition>
<!-- Footer -->
<div :class="$style.footer">
<ConfirmationFooter layout="row-between" bordered>
<div :class="$style.pagination">
<N8nButton
variant="ghost"
@@ -554,7 +555,7 @@ function onOptionMouseEnter(idx: number) {
<N8nButton
v-if="showNextButton"
:type="isNextEnabled ? 'primary' : 'secondary'"
:variant="isNextEnabled ? 'solid' : 'outline'"
size="small"
:disabled="disabled || isSubmitted || !isNextEnabled"
data-test-id="instance-ai-questions-next"
@@ -563,7 +564,7 @@ function onOptionMouseEnter(idx: number) {
{{ nextButtonLabel }}
</N8nButton>
</div>
</div>
</ConfirmationFooter>
</div>
</div>
</template>
@@ -758,14 +759,6 @@ function onOptionMouseEnter(idx: number) {
flex-shrink: 0;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.pagination {
display: flex;
align-items: center;
@@ -31,6 +31,7 @@ import { useRootStore } from '@n8n/stores/useRootStore';
import { NodeHelpers, isResourceLocatorValue, type INodeProperties } from 'n8n-workflow';
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { useInstanceAiStore } from '../instanceAi.store';
import ConfirmationFooter from './ConfirmationFooter.vue';
// ---------------------------------------------------------------------------
// Types
@@ -1038,7 +1039,7 @@ async function handleLater() {
</li>
</ul>
</div>
<footer :class="$style.footer">
<ConfirmationFooter layout="row-between">
<div :class="$style.footerNav">
<N8nLink
data-test-id="instance-ai-workflow-setup-review-details"
@@ -1067,7 +1068,7 @@ async function handleLater() {
@click="handleApply"
/>
</div>
</footer>
</ConfirmationFooter>
</div>
<div
v-else-if="currentCard"
@@ -1214,7 +1215,7 @@ async function handleLater() {
</div>
<!-- Footer -->
<footer :class="$style.footer">
<ConfirmationFooter layout="row-between">
<div :class="$style.footerNav">
<N8nButton
v-if="showArrows"
@@ -1274,7 +1275,7 @@ async function handleLater() {
@click="handleApply"
/>
</div>
</footer>
</ConfirmationFooter>
</div>
</template>
@@ -1399,14 +1400,6 @@ async function handleLater() {
margin: 0 var(--spacing--sm);
}
.footer {
display: flex;
align-items: center;
gap: var(--spacing--xs);
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
}
.footerNav {
display: flex;
flex: 1;
@@ -8,6 +8,7 @@
import { N8nBadge, N8nButton, N8nIcon, type IconName } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
import ConfirmationFooter from './ConfirmationFooter.vue';
export interface PlannedTaskArg {
id: string;
@@ -146,7 +147,7 @@ function handleRequestChanges() {
</div>
<!-- Approval footer (hidden during loading and after resolution) -->
<div v-if="!isResolved && !props.readOnly && !props.loading" :class="$style.footer">
<ConfirmationFooter v-if="!isResolved && !props.readOnly && !props.loading" layout="column">
<textarea
v-model="feedback"
:class="$style.feedbackTextarea"
@@ -165,7 +166,7 @@ function handleRequestChanges() {
{{ i18n.baseText('instanceAi.planReview.requestChanges') }}
</N8nButton>
<N8nButton
type="primary"
variant="solid"
size="small"
:disabled="disabled"
data-test-id="instance-ai-plan-approve"
@@ -174,7 +175,7 @@ function handleRequestChanges() {
{{ i18n.baseText('instanceAi.planReview.approve') }}
</N8nButton>
</div>
</div>
</ConfirmationFooter>
</div>
</template>
@@ -338,14 +339,6 @@ function handleRequestChanges() {
white-space: nowrap;
}
.footer {
border-top: var(--border);
padding: var(--spacing--xs) var(--spacing--sm);
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.feedbackTextarea {
width: 100%;
padding: var(--spacing--2xs);
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
variant?: 'solid' | 'outline' | 'destructive';
size?: 'small' | 'medium';
label: string;
items: Array<ActionDropdownItem<string>>;
disabled?: boolean;
dataTestId?: string;
dropdownTestId?: string;
caretAriaLabel?: string;
}>(),
{
variant: 'solid',
size: 'small',
caretAriaLabel: 'More options',
},
);
const emit = defineEmits<{
click: [];
select: [id: string];
}>();
const hasSplit = computed(() => props.items.length > 0);
</script>
<template>
<div v-if="hasSplit" :class="[$style.splitButton, variant === 'outline' && $style.outline]">
<N8nButton
:variant="variant"
:class="$style.splitButtonMain"
:label="label"
:data-test-id="dataTestId"
:size="size"
:disabled="disabled"
@click="emit('click')"
/>
<N8nActionDropdown
:items="items"
:class="$style.splitButtonDropdown"
:data-test-id="dropdownTestId"
placement="bottom-start"
@select="(id: string) => emit('select', id)"
>
<template #activator>
<N8nIconButton
:variant="variant"
icon="chevron-down"
:class="$style.splitButtonCaret"
:aria-label="caretAriaLabel"
:size="size"
:disabled="disabled"
/>
</template>
</N8nActionDropdown>
</div>
<N8nButton
v-else
:variant="variant"
:label="label"
:data-test-id="dataTestId"
:size="size"
:disabled="disabled"
@click="emit('click')"
/>
</template>
<style lang="scss" module>
.splitButton {
display: flex;
position: relative;
}
.splitButtonMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.splitButtonDropdown {
display: flex;
}
.splitButtonCaret {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
// Outline: put the border on the wrapper, strip it from individual
// buttons, and use a pseudo-element on the dropdown as the divider.
.outline {
border: 1px solid light-dark(var(--color--black-alpha-200), var(--color--white-alpha-100));
border-radius: var(--radius);
.splitButtonMain,
.splitButtonCaret {
--button--border--shadow: 0 0 0 0 transparent;
--button--border--shadow--hover: 0 0 0 0 transparent;
--button--border--shadow--active: 0 0 0 0 transparent;
border-left: 0;
}
.splitButtonDropdown {
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: light-dark(var(--color--black-alpha-200), var(--color--white-alpha-100));
pointer-events: none;
}
}
}
</style>