fix(ai-builder): Reactive subject name on chip (#39097)

This commit is contained in:
Anne Aguirre 2026-09-18 14:14:58 +00:00 committed by GitHub
parent 7683e1c7f6
commit 233afff41c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 98 additions and 2 deletions

View File

@ -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<void>;
/**
* 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,
};

View File

@ -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(

View File

@ -385,6 +385,9 @@ const ThreadScope = defineComponent({
// defined inside the panel's own `<script setup>`, and this is the
// only place that can reach the mounted conversation for `handoff()`.
ref: conversationRef,
// Forward the live subject so the chat-input context chip follows a
// host rename instead of the snapshot stashed at thread mint.
subject: props.subject,
beforeSend: props.beforeSend,
onThreadMissing: () => scopeEmit('thread-missing'),
});