feat(chat): show a turn's dropped objects above its question

This commit is contained in:
Enzo Martellucci
2026-08-02 11:29:47 +02:00
parent b598025eef
commit 8cdc27472f
10 changed files with 228 additions and 40 deletions

View File

@@ -342,7 +342,9 @@ provider-agnostic and needs no changes.
same-origin URLs are read. They stay attached until removed with the chip's
X or until the conversation is cleared, and travel as page context on every
turn — so they are hints the assistant verifies with a tool, exactly like
the page's own resource.
the page's own resource. Each message records the objects it was sent with
above the question, as links back to them: a snapshot of the context that
turn carried, kept even after the object is detached.
- **Attachments live in the turn**: file text counts toward
`MAX_INPUT_CHARS`, and both files and images leave context once history

View File

@@ -28,10 +28,7 @@ import {
Typography,
} from 'antd';
import {
BarChartOutlined,
CloseCircleFilled,
DatabaseOutlined,
DashboardOutlined,
EyeOutlined,
PaperClipOutlined,
PlusOutlined,
@@ -48,27 +45,11 @@ import {
} from '../utils/attachments';
import { droppedText, referenceKey } from '../utils/entityRef';
import type { EntityReferences } from '../hooks/useEntityReferences';
import type { ResourceContext } from '../types';
import ReferenceTag from './ReferenceTag';
const { t } = translation;
const { useTheme } = theme;
const REFERENCE_ICON: Record<ResourceContext['kind'], React.ReactNode> = {
dashboard: <DashboardOutlined />,
chart: <BarChartOutlined />,
dataset: <DatabaseOutlined />,
};
function referenceLabel(reference: ResourceContext): string {
const kind: Record<ResourceContext['kind'], string> = {
dashboard: t('Dashboard'),
chart: t('Chart'),
dataset: t('Dataset'),
};
// Until the name resolves, the id is what identifies it.
return reference.name || `${kind[reference.kind]} ${reference.id_or_slug}`;
}
interface ChatInputProps {
disabled: boolean;
busy: boolean;
@@ -191,27 +172,12 @@ export default function ChatInput({
data-test="chat-references"
>
{entities.references.map(reference => (
<Tag
<ReferenceTag
key={referenceKey(reference)}
icon={REFERENCE_ICON[reference.kind]}
closable
reference={reference}
onClose={() => entities.remove(referenceKey(reference))}
data-test="chat-reference"
title={referenceLabel(reference)}
// Same tokens as the host's secondary button, matching the
// scope tag in the header. Set through `style` rather than
// Tag's `color` prop, which pairs a custom background with
// white text.
style={{
color: theme.buttonSecondaryColor || theme.colorPrimary,
background: theme.buttonSecondaryBg || theme.colorPrimaryBg,
borderColor: theme.buttonSecondaryBorderColor || 'transparent',
maxWidth: 200,
overflow: 'hidden',
}}
>
{referenceLabel(reference)}
</Tag>
/>
))}
{dragging && entities.references.length === 0 && (
<Typography.Text type="secondary">

View File

@@ -557,6 +557,11 @@ test('dragging a chart in pins it as context for every later message', async ()
{ kind: 'chart', id_or_slug: '100' },
]);
// The question records what it was asked about, linked to the chart.
const tag = screen.getByTestId('chat-message-reference');
expect(tag).toHaveTextContent('Chart 100');
expect(tag.closest('a')).toHaveAttribute('href', '/explore/?slice_id=100');
// Still attached for the next question: that is the point of dropping it.
await userEvent.type(input, 'and now?{Enter}');
await waitFor(() =>
@@ -586,6 +591,34 @@ test('a dropped object can be removed, and duplicates are ignored', async () =>
);
});
test('a question keeps the context it was asked with once detached', async () => {
mockConfigAndChat(ENABLED_CONFIG, [
{ type: 'message.completed', id: 'm1', content: 'Sure.' },
{ type: 'request.completed' },
]);
render(<ChatPanel />);
const input = await screen.findByTestId('chat-input');
await waitFor(() => expect(input).toBeEnabled());
dropUrl(
screen.getByTestId('chat-composer'),
'/superset/dashboard/world_health/',
);
await screen.findByTestId('chat-reference');
await userEvent.type(input, 'what is in here?{Enter}');
await screen.findByText('Sure.');
// Detaching stops it riding along with later turns. The transcript still
// shows what this one carried, since that is what the model was asked.
await userEvent.click(screen.getByLabelText('Close'));
await waitFor(() =>
expect(screen.queryByTestId('chat-reference')).toBeNull(),
);
const tag = screen.getByTestId('chat-message-reference');
expect(tag).toHaveTextContent('Dashboard world_health');
expect(tag.closest('a')).toHaveAttribute('href', '/dashboard/world_health/');
});
test('dropping something that is not a Superset object explains itself', async () => {
mockConfigAndChat(ENABLED_CONFIG, []);
render(<ChatPanel />);

View File

@@ -113,6 +113,7 @@ export default function ChatPanel() {
content,
sent,
attachments: attachmentRefs(attachments),
references: entities.references,
images,
});
run(signal =>
@@ -186,6 +187,7 @@ export default function ChatPanel() {
data-test="ai-chat-panel"
vertical
style={{
boxSizing: 'border-box',
width: mode === 'panel' ? '100%' : 600,
height: mode === 'panel' ? '100%' : 'min(760px, 90vh)',
background: theme.colorBgElevated,

View File

@@ -20,8 +20,10 @@ import React, { useEffect, useRef } from 'react';
import { Flex, Image, Spin, Tag, Typography } from 'antd';
import { PaperClipOutlined } from '@ant-design/icons';
import { theme, translation } from '@apache-superset/core';
import { referenceKey } from '../utils/entityRef';
import type { DisplayItem, FoldSignal } from '../types';
import AssistantMessage from './AssistantMessage';
import ReferenceTag from './ReferenceTag';
import ToolCallCard from './ToolCallCard';
const { t } = translation;
@@ -69,6 +71,25 @@ function MessageBubble({
overflowWrap: 'break-word',
}}
>
{item.references?.length ? (
// Above the question, as they were in the composer when it was
// asked, and linked so the object is one click away
<Flex
wrap
gap={theme.marginXXS}
style={{ marginBottom: theme.marginXXS }}
data-test="chat-message-references"
>
{item.references.map(reference => (
<ReferenceTag
key={referenceKey(reference)}
reference={reference}
linked
data-test="chat-message-reference"
/>
))}
</Flex>
) : null}
<Typography.Text style={{ color: 'inherit' }}>
{item.content}
</Typography.Text>

View File

@@ -0,0 +1,105 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { Tag } from 'antd';
import {
BarChartOutlined,
DashboardOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import { theme, translation } from '@apache-superset/core';
import { entityHref } from '../utils/entityRef';
import type { ResourceContext } from '../types';
const { t } = translation;
const { useTheme } = theme;
const REFERENCE_ICON: Record<ResourceContext['kind'], React.ReactNode> = {
dashboard: <DashboardOutlined />,
chart: <BarChartOutlined />,
dataset: <DatabaseOutlined />,
};
export function referenceLabel(reference: ResourceContext): string {
const kind: Record<ResourceContext['kind'], string> = {
dashboard: t('Dashboard'),
chart: t('Chart'),
dataset: t('Dataset'),
};
// Until the name resolves, the id is what identifies it.
return reference.name || `${kind[reference.kind]} ${reference.id_or_slug}`;
}
interface ReferenceTagProps {
reference: ResourceContext;
/** Detaches it; omitted where the tag records what a turn already carried */
onClose?: () => void;
/** Turns the whole chip into a link to the object it names */
linked?: boolean;
'data-test'?: string;
}
/**
* One dropped dashboard, chart or dataset, shown the same way wherever it
* appears: staged in the composer, where it can be detached, and above the
* message it was sent with, where it links to the object instead.
*/
export default function ReferenceTag({
reference,
onClose,
linked,
'data-test': dataTest,
}: ReferenceTagProps) {
const theme = useTheme();
const label = referenceLabel(reference);
const tag = (
<Tag
icon={REFERENCE_ICON[reference.kind]}
closable={Boolean(onClose)}
onClose={onClose}
data-test={dataTest}
title={label}
// Same tokens as the host's secondary button, matching the scope tag in
// the header. Set through `style` rather than Tag's `color` prop, which
// pairs a custom background with white text.
style={{
color: theme.buttonSecondaryColor || theme.colorPrimary,
background: theme.buttonSecondaryBg || theme.colorPrimaryBg,
borderColor: theme.buttonSecondaryBorderColor || 'transparent',
maxWidth: 200,
overflow: 'hidden',
// Both rows space their tags with a Flex gap
marginInlineEnd: 0,
}}
>
{label}
</Tag>
);
return linked ? (
<a
href={entityHref(reference)}
aria-label={t('Open %s', label)}
data-test="chat-reference-link"
>
{tag}
</a>
) : (
tag
);
}

View File

@@ -32,6 +32,7 @@ import type {
PendingApproval,
ProtocolImage,
ProtocolMessage,
ResourceContext,
} from '../types';
export const MAX_HISTORY_MESSAGES = 60;
@@ -66,6 +67,8 @@ export type ConversationAction =
*/
sent?: string;
attachments?: AttachmentRef[];
/** Dropped objects this turn carried, recorded beside the message */
references?: ResourceContext[];
images?: ProtocolImage[];
}
| { type: 'events'; events: ChatEvent[] }
@@ -322,6 +325,9 @@ export function conversationReducer(
...(action.attachments?.length
? { attachments: action.attachments }
: {}),
...(action.references?.length
? { references: action.references }
: {}),
},
],
history: trimHistory([

View File

@@ -184,6 +184,12 @@ export type DisplayItem =
content: string;
/** File names shown in the transcript; their text lives in history */
attachments?: AttachmentRef[];
/**
* Objects that were attached when the turn was sent. A snapshot, not a
* view of what is attached now: the transcript records the context each
* question actually carried, and references come and go between turns.
*/
references?: ResourceContext[];
}
| {
kind: 'tool';

View File

@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { droppedText, parseEntityUrl, referenceKey } from './entityRef';
import {
droppedText,
entityHref,
parseEntityUrl,
referenceKey,
} from './entityRef';
test('a chart title dragged from a dashboard names its chart', () => {
// The href Superset renders on a dashboard chart header.
@@ -73,6 +78,27 @@ test('references are keyed by kind and id', () => {
expect(referenceKey({ kind: 'chart', id_or_slug: '100' })).toBe('chart:100');
});
test('every reference links back to what it names', () => {
const references = [
{ kind: 'dashboard', id_or_slug: 'world_health' },
{ kind: 'dashboard', id_or_slug: '5' },
{ kind: 'chart', id_or_slug: '100' },
{ kind: 'dataset', id_or_slug: '42' },
] as const;
// Whatever could be attached can be opened: the link parses back to it.
references.forEach(reference =>
expect(parseEntityUrl(entityHref(reference))).toEqual(reference),
);
expect(entityHref(references[0])).toBe('/dashboard/world_health/');
expect(entityHref(references[2])).toBe('/explore/?slice_id=100');
});
test('a slug is escaped rather than trusted into the link', () => {
expect(entityHref({ kind: 'dashboard', id_or_slug: '../../evil?x=1' })).toBe(
'/dashboard/..%2F..%2Fevil%3Fx%3D1/',
);
});
test('a drop prefers the uri-list flavour over plain text', () => {
const transfer = {
getData: (type: string) =>

View File

@@ -96,6 +96,27 @@ export function parseEntityUrl(raw: string): ResourceContext | null {
return null;
}
/**
* Where a reference points, in the same form Superset's own models build, so
* a tag in the transcript opens what the user dropped. Kept as a path so the
* link can only lead back into this instance.
*
* `parseEntityUrl` reads these back, which is what the round-trip test pins:
* a reference that could be attached is a reference that can be opened.
*/
export function entityHref(reference: ResourceContext): string {
const id = encodeURIComponent(reference.id_or_slug);
switch (reference.kind) {
case 'dashboard':
return `/dashboard/${id}/`;
case 'chart':
return `/explore/?slice_id=${id}`;
// Only table datasources are ever parsed into a reference.
default:
return `/explore/?datasource_type=table&datasource_id=${id}`;
}
}
/** The URL text a drop carries, in the order browsers prefer. */
export function droppedText(transfer: DataTransfer | null): string {
if (!transfer) return '';