## Summary - Closes [#108](#108) — hide Sources card; clicking inline `[n]` opens that source in a new tab. - Follow-up to [#106](#106) / [#107](#107): default fixed `modelName` is now `THINKING` (not `FAST`). ## Test plan - [ ] Sent messages use THINKING. - [ ] Grounded answers show inline `[n]` markers but no Sources card. - [ ] Clicking `[n]` opens the matching URL in a new tab.Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
+10
-16
@@ -36,7 +36,7 @@ describe('ConversationDetailCard citations (#98)', () => {
|
|||||||
expect(out).toContain('```\n[9]\n```');
|
expect(out).toContain('```\n[9]\n```');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Sources list from citations prop', () => {
|
it('does not render Sources list card', () => {
|
||||||
renderCard({
|
renderCard({
|
||||||
message: 'Answer with [1]',
|
message: 'Answer with [1]',
|
||||||
user_created: false,
|
user_created: false,
|
||||||
@@ -49,24 +49,13 @@ describe('ConversationDetailCard citations (#98)', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
|
|
||||||
const link = screen.getByRole('link', { name: 'Example Source' });
|
|
||||||
expect(link).toHaveAttribute('href', 'https://example.com/a');
|
|
||||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
||||||
expect(link).toHaveAttribute('target', '_blank');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing for Sources when citations empty', () => {
|
|
||||||
renderCard({
|
|
||||||
message: 'No sources here',
|
|
||||||
user_created: false,
|
|
||||||
citations: [],
|
|
||||||
});
|
|
||||||
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Source 1' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('highlights source when inline citation clicked', async () => {
|
it('opens citation URL in a new tab when inline marker clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
renderCard({
|
renderCard({
|
||||||
message: 'See [1]',
|
message: 'See [1]',
|
||||||
user_created: false,
|
user_created: false,
|
||||||
@@ -75,6 +64,11 @@ describe('ConversationDetailCard citations (#98)', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
||||||
expect(document.getElementById('citation-source-1')).toBeInTheDocument();
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://example.com',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
openSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import Markdown from 'markdown-to-jsx';
|
import Markdown from 'markdown-to-jsx';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled, { keyframes } from 'styled-components';
|
import styled, { keyframes } from 'styled-components';
|
||||||
@@ -6,7 +6,6 @@ import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatE
|
|||||||
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
|
import type { ActivityHistoryEntry, Citation } from '../../utils/wsFrames';
|
||||||
import type { PromptRating } from '../../utils/promptFeedback';
|
import type { PromptRating } from '../../utils/promptFeedback';
|
||||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
||||||
import SourcesList from '../SourcesList/SourcesList';
|
|
||||||
import MessageActions from '../MessageActions/MessageActions';
|
import MessageActions from '../MessageActions/MessageActions';
|
||||||
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
|
import ActivityIndicator from '../ActivityIndicator/ActivityIndicator';
|
||||||
|
|
||||||
@@ -209,8 +208,6 @@ const ConversationDetailCard = ({
|
|||||||
activityHistory = [],
|
activityHistory = [],
|
||||||
activityInterrupted = false,
|
activityInterrupted = false,
|
||||||
}: ConversationDetailCardProps): JSX.Element => {
|
}: ConversationDetailCardProps): JSX.Element => {
|
||||||
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const CitationMark = useCallback(
|
const CitationMark = useCallback(
|
||||||
({ indices }: { indices?: string }) => {
|
({ indices }: { indices?: string }) => {
|
||||||
const list = (indices || '')
|
const list = (indices || '')
|
||||||
@@ -229,12 +226,9 @@ const ConversationDetailCard = ({
|
|||||||
aria-label={`Source ${index}`}
|
aria-label={`Source ${index}`}
|
||||||
title={citation?.title || `Source ${index}`}
|
title={citation?.title || `Source ${index}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHighlightIndex(index);
|
|
||||||
if (citation?.url) {
|
if (citation?.url) {
|
||||||
// Prefer scroll/highlight; URL still available from Sources list
|
window.open(citation.url, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
const el = document.getElementById(`citation-source-${index}`);
|
|
||||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
[{index}]
|
[{index}]
|
||||||
@@ -346,10 +340,6 @@ const ConversationDetailCard = ({
|
|||||||
</Markdown>
|
</Markdown>
|
||||||
</Bubble>
|
</Bubble>
|
||||||
|
|
||||||
{!user_created && citations.length > 0 && (
|
|
||||||
<SourcesList citations={citations} highlightIndex={highlightIndex} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MessageActions
|
<MessageActions
|
||||||
promptId={promptId}
|
promptId={promptId}
|
||||||
rawMarkdown={rawForCopy}
|
rawMarkdown={rawForCopy}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import type { Citation } from '../../utils/wsFrames';
|
|
||||||
|
|
||||||
const SourcesRoot = styled.aside`
|
|
||||||
margin-top: 0.65rem;
|
|
||||||
max-width: 80%;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.65rem 0.85rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
|
||||||
background: ${({ theme }) =>
|
|
||||||
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
|
|
||||||
color: ${({ theme }) => theme.colors.text};
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
max-width: 92%;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SourcesTitle = styled.h4`
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.75;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SourceList = styled.ol`
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SourceItem = styled.li<{ $highlight: boolean }>`
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
padding: 0.15rem 0.25rem;
|
|
||||||
outline: ${({ $highlight, theme }) =>
|
|
||||||
$highlight ? `2px solid ${theme.main}` : 'none'};
|
|
||||||
background: ${({ $highlight, theme }) =>
|
|
||||||
$highlight
|
|
||||||
? theme.darkMode
|
|
||||||
? 'rgba(255,255,255,0.08)'
|
|
||||||
: 'rgba(0,0,0,0.06)'
|
|
||||||
: 'transparent'};
|
|
||||||
transition: background 0.2s ease, outline 0.2s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SourceLink = styled.a`
|
|
||||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
|
||||||
text-decoration: underline;
|
|
||||||
word-break: break-word;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SourceMeta = styled.span`
|
|
||||||
display: block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.65;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function domainFromUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname.replace(/^www\./, '');
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourcesListProps = {
|
|
||||||
citations: Citation[];
|
|
||||||
highlightIndex?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SourcesList = ({
|
|
||||||
citations,
|
|
||||||
highlightIndex = null,
|
|
||||||
}: SourcesListProps): JSX.Element | null => {
|
|
||||||
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
|
|
||||||
const [active, setActive] = useState<number | null>(highlightIndex);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(highlightIndex ?? null);
|
|
||||||
if (highlightIndex == null) return;
|
|
||||||
const el = itemRefs.current[highlightIndex];
|
|
||||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}, [highlightIndex]);
|
|
||||||
|
|
||||||
if (!citations.length) return null;
|
|
||||||
|
|
||||||
const sorted = [...citations].sort((a, b) => a.index - b.index);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SourcesRoot aria-label="Sources">
|
|
||||||
<SourcesTitle>Sources</SourcesTitle>
|
|
||||||
<SourceList>
|
|
||||||
{sorted.map((citation) => {
|
|
||||||
const domain = domainFromUrl(citation.url);
|
|
||||||
return (
|
|
||||||
<SourceItem
|
|
||||||
key={citation.index}
|
|
||||||
id={`citation-source-${citation.index}`}
|
|
||||||
$highlight={active === citation.index}
|
|
||||||
ref={(node) => {
|
|
||||||
itemRefs.current[citation.index] = node;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{citation.url ? (
|
|
||||||
<SourceLink
|
|
||||||
href={citation.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{citation.title || `Source ${citation.index}`}
|
|
||||||
</SourceLink>
|
|
||||||
) : (
|
|
||||||
<span>{citation.title || `Source ${citation.index}`}</span>
|
|
||||||
)}
|
|
||||||
{(citation.published_at || domain) && (
|
|
||||||
<SourceMeta>
|
|
||||||
{[citation.published_at, domain].filter(Boolean).join(' · ')}
|
|
||||||
</SourceMeta>
|
|
||||||
)}
|
|
||||||
</SourceItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SourceList>
|
|
||||||
</SourcesRoot>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SourcesList;
|
|
||||||
@@ -318,7 +318,7 @@ const validationSchema = Yup.object().shape({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Fixed until product re-exposes a model picker (#106). */
|
/** Fixed until product re-exposes a model picker (#106). */
|
||||||
const DEFAULT_MODEL_NAME = "FAST";
|
const DEFAULT_MODEL_NAME = "THINKING";
|
||||||
|
|
||||||
type PromptValues = {
|
type PromptValues = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user