Skip to content

Commit 9182c07

Browse files
feat(lightspeed): add Sources chip + popover for notebook context
Replace the inline SourcesCard with a compact chip button that opens a PatternFly Popover showing source documents with FileTypeIcon badges. Scoped to Notebook view only; general chat retains the original display. Includes i18n translations for all 5 languages, unit tests, and consistent source document handling during streaming. Signed-off-by: its-mitesh-kumar <itsmiteshkumar98@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c990755 commit 9182c07

13 files changed

Lines changed: 446 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
3+
---
4+
5+
Replace inline SourcesCard with a compact chip + popover for notebook sources display, using PatternFly's native Popover component with custom FileTypeIcon badges and i18n support for all languages.

workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ export const lightspeedTranslationRef: TranslationRef<
272272
readonly 'sort.oldest': string;
273273
readonly 'sort.alphabeticalAsc': string;
274274
readonly 'sort.alphabeticalDesc': string;
275+
readonly 'sources.chip.label': string;
276+
readonly 'sources.modal.title': string;
277+
readonly 'sources.modal.description': string;
278+
readonly 'sources.popover.closeAriaLabel': string;
275279
readonly 'reasoning.thinking': string;
276280
}
277281
>;

workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBox.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useTranslation } from '../hooks/useTranslation';
4444
import { ToolCall } from '../types';
4545
import { parseReasoning } from '../utils/reasoningParser';
4646
import { mapToPatternFlyToolCall } from '../utils/toolCallMapper';
47+
import { SourcesChipModal } from './SourcesChipModal';
4748

4849
const useStyles = makeStyles(theme => ({
4950
prompt: {
@@ -114,6 +115,8 @@ type LightspeedChatBoxProps = {
114115
conversationId: string;
115116
isStreaming: boolean;
116117
displayMode?: ChatbotDisplayMode;
118+
/** When true, sources are shown as a compact chip + modal instead of the inline SourcesCard. */
119+
useSourcesChipModal?: boolean;
117120
};
118121

119122
export interface ScrollContainerHandle {
@@ -132,6 +135,7 @@ export const LightspeedChatBox = forwardRef(
132135
isStreaming,
133136
topicRestrictionEnabled,
134137
displayMode,
138+
useSourcesChipModal: showSourcesChipModal = false,
135139
}: LightspeedChatBoxProps,
136140
ref: ForwardedRef<ScrollContainerHandle | null>,
137141
) => {
@@ -262,6 +266,7 @@ export const LightspeedChatBox = forwardRef(
262266
const extraContentParts: {
263267
beforeMainContent?: React.ReactNode;
264268
afterMainContent?: React.ReactNode;
269+
endContent?: React.ReactNode;
265270
} = {};
266271

267272
let deepThinking: DeepThinkingProps | undefined = undefined;
@@ -331,18 +336,34 @@ export const LightspeedChatBox = forwardRef(
331336
);
332337
}
333338

334-
const extraContent =
335-
extraContentParts.beforeMainContent ||
336-
extraContentParts.afterMainContent
337-
? extraContentParts
338-
: undefined;
339-
340-
const finalMessage =
339+
const messageToPrepare =
341340
parsedReasoning.hasReasoning ||
342341
parsedReasoning.isReasoningInProgress
343342
? { ...message, content: parsedReasoning.mainContent }
344343
: message;
345344

345+
let finalMessage = messageToPrepare;
346+
347+
if (showSourcesChipModal) {
348+
const { sources: messageSources, ...messageWithoutSources } =
349+
messageToPrepare;
350+
351+
if (messageSources?.sources?.length) {
352+
extraContentParts.endContent = (
353+
<SourcesChipModal sources={messageSources} />
354+
);
355+
}
356+
357+
finalMessage = messageWithoutSources;
358+
}
359+
360+
const extraContent =
361+
extraContentParts.beforeMainContent ||
362+
extraContentParts.afterMainContent ||
363+
extraContentParts.endContent
364+
? extraContentParts
365+
: undefined;
366+
346367
return (
347368
<Message
348369
key={`${message.role}-${index}`}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { makeStyles } from '@material-ui/core';
18+
import { SourcesCardProps } from '@patternfly/chatbot';
19+
import { Button, Popover } from '@patternfly/react-core';
20+
import { InfoCircleIcon, LinkIcon } from '@patternfly/react-icons';
21+
22+
import { useTranslation } from '../hooks/useTranslation';
23+
import { FileTypeIcon } from './notebooks/FileTypeIcon';
24+
25+
const POPOVER_WIDTH = '400px';
26+
27+
const useStyles = makeStyles(theme => ({
28+
sourcesPopover: {
29+
'& .pf-v6-c-popover__title': {
30+
alignItems: 'flex-start',
31+
},
32+
'& .pf-v6-c-popover__title-text': {
33+
margin: 0,
34+
},
35+
'& .pf-v6-c-popover__header': {
36+
paddingBlockEnd: theme.spacing(2),
37+
},
38+
'& .pf-v6-c-popover__body': {
39+
paddingBlockStart: 0,
40+
},
41+
},
42+
chipButton: {
43+
padding: '4px 12px',
44+
fontSize: '0.8125rem',
45+
fontWeight: 500,
46+
borderRadius: 16,
47+
backgroundColor: 'transparent',
48+
color: 'var(--pf-t--global--text--color--regular, #1b1d21)',
49+
'&:hover': {
50+
backgroundColor:
51+
'var(--pf-t--global--background--color--secondary--default, #e0e0e0)',
52+
},
53+
'& .pf-v6-c-button__icon': {
54+
color: 'inherit',
55+
},
56+
},
57+
infoRow: {
58+
display: 'flex',
59+
alignItems: 'flex-start',
60+
gap: theme.spacing(1),
61+
marginBottom: theme.spacing(1.5),
62+
fontSize: '0.8125rem',
63+
color: 'var(--pf-t--global--text--color--subtle, #c7c7c7)',
64+
},
65+
infoIcon: {
66+
flexShrink: 0,
67+
marginTop: 2,
68+
fontSize: '0.875rem',
69+
color: 'var(--pf-t--global--icon--color--status--info--default, #2b9af3)',
70+
},
71+
sourcesList: {
72+
listStyle: 'none',
73+
margin: 0,
74+
padding: 0,
75+
maxHeight: 320,
76+
overflowY: 'auto',
77+
},
78+
sourceItem: {
79+
display: 'flex',
80+
alignItems: 'center',
81+
gap: theme.spacing(1.5),
82+
padding: `${theme.spacing(1.5)}px 0`,
83+
borderBottom: '1px solid var(--pf-t--global--border--color--default, #444)',
84+
'&:last-child': {
85+
borderBottom: 'none',
86+
},
87+
},
88+
sourceContent: {
89+
flex: 1,
90+
minWidth: 0,
91+
},
92+
sourceTitleButton: {
93+
background: 'none',
94+
border: 'none',
95+
cursor: 'pointer',
96+
textAlign: 'left',
97+
padding: 0,
98+
font: 'inherit',
99+
fontSize: '0.875rem',
100+
fontWeight: 500,
101+
color: 'var(--pf-t--global--text--color--link--default, #2b9af3)',
102+
'&:hover': {
103+
textDecoration: 'underline',
104+
},
105+
},
106+
sourceTitlePlain: {
107+
fontSize: '0.875rem',
108+
fontWeight: 500,
109+
},
110+
sourceBody: {
111+
fontSize: '0.8125rem',
112+
color: 'var(--pf-t--global--text--color--subtle, #c7c7c7)',
113+
marginTop: 2,
114+
overflow: 'hidden',
115+
textOverflow: 'ellipsis',
116+
display: '-webkit-box',
117+
WebkitLineClamp: 2,
118+
WebkitBoxOrient: 'vertical',
119+
},
120+
}));
121+
122+
type SourcesChipModalProps = {
123+
sources: SourcesCardProps;
124+
};
125+
126+
export const SourcesChipModal = ({ sources }: SourcesChipModalProps) => {
127+
const classes = useStyles();
128+
const { t } = useTranslation();
129+
130+
const count = sources.sources?.length ?? 0;
131+
if (count === 0) return null;
132+
133+
const handleSourceClick = (link: string, isExternal?: boolean) => {
134+
if (link) {
135+
window.open(
136+
link,
137+
isExternal ? '_blank' : '_self',
138+
isExternal ? 'noreferrer' : undefined,
139+
);
140+
}
141+
};
142+
143+
return (
144+
<Popover
145+
className={classes.sourcesPopover}
146+
position="top-start"
147+
triggerAction="click"
148+
aria-label={t('sources.modal.title')}
149+
headerContent={t('sources.modal.title')}
150+
headerIcon={<LinkIcon />}
151+
closeBtnAriaLabel={t('sources.popover.closeAriaLabel')}
152+
minWidth={POPOVER_WIDTH}
153+
maxWidth={POPOVER_WIDTH}
154+
appendTo={() => document.body}
155+
bodyContent={
156+
<>
157+
<div className={classes.infoRow}>
158+
<InfoCircleIcon className={classes.infoIcon} />
159+
<span>{t('sources.modal.description')}</span>
160+
</div>
161+
<ul className={classes.sourcesList}>
162+
{sources.sources.map((source, index) => {
163+
const title = source.title ?? `Source ${index + 1}`;
164+
return (
165+
<li
166+
key={`${source.title}-${index}`}
167+
className={classes.sourceItem}
168+
>
169+
<FileTypeIcon fileName={title} />
170+
<div className={classes.sourceContent}>
171+
{source.link ? (
172+
<button
173+
type="button"
174+
className={classes.sourceTitleButton}
175+
onClick={() =>
176+
handleSourceClick(source.link, source.isExternal)
177+
}
178+
>
179+
{title}
180+
</button>
181+
) : (
182+
<div className={classes.sourceTitlePlain}>{title}</div>
183+
)}
184+
{source.body && (
185+
<div className={classes.sourceBody}>{source.body}</div>
186+
)}
187+
</div>
188+
</li>
189+
);
190+
})}
191+
</ul>
192+
</>
193+
}
194+
>
195+
<Button variant="link" icon={<LinkIcon />} className={classes.chipButton}>
196+
{(t as Function)('sources.chip.label', { count: String(count) })}
197+
</Button>
198+
</Popover>
199+
);
200+
};

0 commit comments

Comments
 (0)