From 233afff41c75ff0b3d8fcbf2eac100c8d1b5039a Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Fri, 18 Sep 2026 14:14:58 +0000 Subject: [PATCH] fix(ai-builder): Reactive subject name on chip (#39097) --- .../components/InstanceAiConversation.vue | 18 ++++- .../__tests__/InstanceAiConversation.test.ts | 79 ++++++++++++++++++- .../instanceAi/embed/InstanceAiChatPanel.vue | 3 + 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue index 15060788104..205d77f5150 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConversation.vue @@ -59,6 +59,7 @@ import { getDismissedContextKeys, handoffContextKey, } from '../instanceAi.handoffContext'; +import type { InstanceAiEmbedSubject } from '../embed/instanceAiEmbed.types'; import InstanceAiMessage from './InstanceAiMessage.vue'; import InstanceAiInput from './InstanceAiInput.vue'; import InstanceAiMarkdown from './InstanceAiMarkdown.vue'; @@ -73,6 +74,14 @@ import CreditWarningBanner from '@/features/ai/assistant/components/Agent/Credit const props = defineProps<{ /** Runs before every send (e.g. flush a pending autosave). Rejecting cancels the send. */ beforeSend?: () => Promise; + /** + * The live embed subject (an agent in the builder today). When it refers to + * the same agent as the stashed `pendingAgentAttachment`, the chat-input + * context chip follows this subject's `name` instead of the snapshot taken + * at thread mint — so a rename in the host updates the chip live. Optional: + * the full thread view passes no subject and keeps the stashed-name behavior. + */ + subject?: InstanceAiEmbedSubject; /** Extra scroll space for a panel that overlays messages above the input. */ aboveInputOverlapHeight?: number; }>(); @@ -165,6 +174,13 @@ const hasFloatingConfirmation = computed(() => const composerContextChip = computed(() => { const agentAttachment = currentAgentAttachment.value; if (agentAttachment && pendingComposerContext.value?.source !== 'agent-preview') { + // Prefer the host's live subject name when it refers to the same agent as + // the stashed attachment, so a rename in the builder updates the chip + // without re-stashing. Falls back to the stashed snapshot otherwise. + const liveSubjectName = + props.subject?.type === 'agent' && props.subject.id === agentAttachment.id + ? props.subject.name + : undefined; return { type: 'agent-artifact' as const, agentId: agentAttachment.id, @@ -173,7 +189,7 @@ const composerContextChip = computed(() => { pendingAgentAttachment.value?.id === agentAttachment.id && pendingAgentAttachment.value.pending === true, key: `pending-agent:${agentAttachment.id}`, - label: agentAttachment.name ?? i18n.baseText('agents.new.defaultName'), + label: liveSubjectName ?? agentAttachment.name ?? i18n.baseText('agents.new.defaultName'), icon: 'robot', isPending: true, }; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts index 708477cc79b..c6ebfcab36b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/InstanceAiConversation.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { defineComponent, h } from 'vue'; +import { defineComponent, h, nextTick, reactive } from 'vue'; import { mount } from '@vue/test-utils'; import { fireEvent } from '@testing-library/vue'; import { createTestingPinia } from '@pinia/testing'; @@ -20,6 +20,7 @@ import { stashPendingRedirectLanding, stashPendingWorkflowAttachment, } from '../../composables/useInstanceAiHandoff'; +import type { InstanceAiEmbedSubject } from '../../embed/instanceAiEmbed.types'; import type { InstanceAiHandoffContext, InstanceAiMessage } from '@n8n/api-types'; import { ResponseError } from '@n8n/rest-api-client'; import { USER_TYPED_MESSAGE } from '../../prefills'; @@ -157,6 +158,82 @@ describe('InstanceAiConversation', () => { ]); }); + describe('composer context chip label', () => { + // Wraps the subject in `reactive` and returns it alongside the render + // result, so a test can mutate `subject.name` after mount and assert the + // chip follows the live value — the actual AGENT-954 scenario (a rename in + // the builder while the panel stays open). + function mountWithSubject(subject: InstanceAiEmbedSubject | undefined) { + thread.sseState = 'disconnected'; + stashPendingAgentAttachment('thread-1', { + type: 'agent', + id: 'agent-1', + projectId: 'proj-1', + name: 'Stashed Name', + pending: true, + }); + const reactiveSubject = subject === undefined ? undefined : reactive({ ...subject }); + const renderer = createThreadComponentRenderer( + InstanceAiConversation, + { + props: { subject: reactiveSubject }, + global: { stubs: { InstanceAiInput: InstanceAiInputStub } }, + }, + () => thread, + ); + return { ...renderer(), subject: reactiveSubject }; + } + + it('prefers the live subject name over the stashed name when agent ids match', async () => { + const { getByTestId } = mountWithSubject({ + type: 'agent', + id: 'agent-1', + projectId: 'proj-1', + name: 'Renamed Live', + }); + await vi.waitFor(() => + expect(getByTestId('instance-ai-input-context-chip').textContent).toBe('Renamed Live'), + ); + }); + + it('updates the chip when the live subject is renamed after mount', async () => { + const { getByTestId, subject } = mountWithSubject({ + type: 'agent', + id: 'agent-1', + projectId: 'proj-1', + name: 'Initial Name', + }); + await vi.waitFor(() => + expect(getByTestId('instance-ai-input-context-chip').textContent).toBe('Initial Name'), + ); + + // A rename in the builder mutates the reactive subject's name; the chip + // must follow it without a re-stash or remount. + subject!.name = 'Renamed Mid-Session'; + await nextTick(); + expect(getByTestId('instance-ai-input-context-chip').textContent).toBe('Renamed Mid-Session'); + }); + + it('falls back to the stashed name when the subject refers to a different agent', async () => { + const { getByTestId } = mountWithSubject({ + type: 'agent', + id: 'agent-other', + projectId: 'proj-1', + name: 'Renamed Live', + }); + await vi.waitFor(() => + expect(getByTestId('instance-ai-input-context-chip').textContent).toBe('Stashed Name'), + ); + }); + + it('falls back to the stashed name when no subject is provided', async () => { + const { getByTestId } = mountWithSubject(undefined); + await vi.waitFor(() => + expect(getByTestId('instance-ai-input-context-chip').textContent).toBe('Stashed Name'), + ); + }); + }); + it('awaits beforeSend before sending, restoring the draft if it rejects', async () => { const beforeSend = vi.fn().mockRejectedValueOnce(new Error('flush failed')); const renderer = createThreadComponentRenderer( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue index f6fbafd36bc..62f0ab8c238 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/embed/InstanceAiChatPanel.vue @@ -385,6 +385,9 @@ const ThreadScope = defineComponent({ // defined inside the panel's own `