Default chat to THINKING; open citations in new tab (#106, #108).

Drop Sources card; inline [n] opens the source URL. Lock composer to THINKING while model picker stays hidden.
This commit is contained in:
2026-08-04 12:22:05 -05:00
parent a13108b840
commit d6cfdeb6f0
4 changed files with 13 additions and 167 deletions
@@ -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;