11'use client'
22
3- import { type ComponentPropsWithoutRef , memo , useEffect , useMemo , useRef , useState } from 'react'
3+ import {
4+ type ComponentPropsWithoutRef ,
5+ createContext ,
6+ memo ,
7+ useContext ,
8+ useEffect ,
9+ useMemo ,
10+ useRef ,
11+ useState ,
12+ } from 'react'
413import { Streamdown } from 'streamdown'
514import 'streamdown/styles.css'
615// prismjs core must load before its language components — they register on the
@@ -15,10 +24,15 @@ import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight }
1524import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1625import { extractTextContent } from '@/lib/core/utils/react-node-text'
1726import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
27+ import {
28+ SourceChip ,
29+ sourceLabel ,
30+ } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
1831import {
1932 type ContentSegment ,
2033 type CredentialSubmissionPayload ,
2134 parseSpecialTags ,
35+ type SourceTagData ,
2236 SpecialTags ,
2337} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2438import type {
@@ -108,9 +122,38 @@ function nextInlineSegmentLabel(segment?: ContentSegment): string {
108122 // Thinking segments are never rendered, so they contribute no following text.
109123 if ( segment . type === 'text' ) return segment . content
110124 if ( segment . type === 'workspace_resource' ) return segment . data . title || segment . data . id || ''
125+ if ( segment . type === 'source' ) return sourceLabel ( segment . data )
111126 return ''
112127}
113128
129+ /**
130+ * The `<source>` payloads of the segment being rendered, in emission order. An
131+ * inline citation is written into the markdown as a link to a sentinel
132+ * fragment carrying the payload's index, so it flows with its paragraph, and
133+ * the link renderer resolves the index back through this context — the
134+ * component map is static, so it is the one channel from segment data into it.
135+ */
136+ const SourceRefsContext = createContext < readonly SourceTagData [ ] > ( [ ] )
137+
138+ /**
139+ * Fragment prefix of a generated citation link. Internal — never navigated —
140+ * and deliberately not a name the model would write on its own; an index that
141+ * resolves to no parsed source falls back to the link text.
142+ */
143+ const SOURCE_LINK_PREFIX = '#sim-source-ref-'
144+
145+ interface SourceReferenceProps {
146+ index : number
147+ children ?: React . ReactNode
148+ }
149+
150+ /** The inline citation chip; a dangling index falls back to the link text. */
151+ function SourceReference ( { index, children } : SourceReferenceProps ) {
152+ const source = useContext ( SourceRefsContext ) [ index ]
153+ if ( ! source ) return < > { children } </ >
154+ return < SourceChip source = { source } />
155+ }
156+
114157function appendInlineReferenceMarkdown (
115158 currentMarkdown : string ,
116159 referenceMarkdown : string ,
@@ -263,6 +306,13 @@ const MARKDOWN_COMPONENTS = {
263306 )
264307 } ,
265308 a ( { children, href } : { children ?: React . ReactNode ; href ?: string } ) {
309+ if ( href ?. startsWith ( SOURCE_LINK_PREFIX ) ) {
310+ return (
311+ < SourceReference index = { Number ( href . slice ( SOURCE_LINK_PREFIX . length ) ) } >
312+ { children }
313+ </ SourceReference >
314+ )
315+ }
266316 if ( href ?. startsWith ( '#wsres-' ) ) {
267317 const match = href . match ( / ^ # w s r e s - ( \w + ) - ( .+ ) $ / )
268318 const type = match ?. [ 1 ]
@@ -566,14 +616,20 @@ function ChatContentInner({
566616
567617 type BlockSegment = Exclude <
568618 ContentSegment ,
569- { type : 'text' } | { type : 'thinking' } | { type : 'workspace_resource' }
619+ { type : 'text' } | { type : 'thinking' } | { type : 'workspace_resource' } | { type : 'source' }
570620 >
571621 type RenderGroup =
572622 | { kind : 'inline' ; markdown : string }
573623 | { kind : 'block' ; segment : BlockSegment ; index : number }
574624
625+ const sourceRefs = useMemo (
626+ ( ) => parsed . segments . flatMap ( ( segment ) => ( segment . type === 'source' ? [ segment . data ] : [ ] ) ) ,
627+ [ parsed ]
628+ )
629+
575630 const groups : RenderGroup [ ] = [ ]
576631 let pendingMarkdown = ''
632+ let sourceIndex = 0
577633
578634 const flushMarkdown = ( ) => {
579635 if ( pendingMarkdown . trim ( ) ) {
@@ -596,6 +652,16 @@ function ChatContentInner({
596652 `[${ label } ](<#wsres-${ s . data . type } -${ ref } >)` ,
597653 nextSegment
598654 )
655+ } else if ( s . type === 'source' ) {
656+ // A citation always stands off from the sentence it supports, even when
657+ // the model closes the sentence on punctuation the word-boundary rule
658+ // would otherwise glue the chip to.
659+ if ( pendingMarkdown && ! / \s $ / . test ( pendingMarkdown ) ) pendingMarkdown += ' '
660+ pendingMarkdown = appendInlineReferenceMarkdown (
661+ pendingMarkdown ,
662+ `[${ sourceLabel ( s . data ) } ](<${ SOURCE_LINK_PREFIX } ${ sourceIndex ++ } >)` ,
663+ nextSegment
664+ )
599665 } else if ( s . type === 'thinking' ) {
600666 // Model-emitted <thinking> tag bodies are reasoning, not answer text —
601667 // never rendered (matches the block-level thinking omission in
@@ -621,40 +687,42 @@ function ChatContentInner({
621687 * the new special block mounts.
622688 */
623689 return (
624- < div className = 'space-y-3' >
625- { groups . map ( ( group , i ) => {
626- if ( group . kind === 'inline' ) {
627- return (
628- < div
629- key = { `inline-${ i } ` }
630- className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) }
631- >
632- < Streamdown
633- key = { streamingTree ? 'stream' : 'settled' }
634- mode = { parserTree ? undefined : 'static' }
635- animated = { fadeActive ? STREAM_ANIMATION : false }
636- isAnimating = { streamingTree }
637- components = { MARKDOWN_COMPONENTS }
690+ < SourceRefsContext . Provider value = { sourceRefs } >
691+ < div className = 'space-y-3' >
692+ { groups . map ( ( group , i ) => {
693+ if ( group . kind === 'inline' ) {
694+ return (
695+ < div
696+ key = { `inline-${ i } ` }
697+ className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) }
638698 >
639- { group . markdown }
640- </ Streamdown >
641- </ div >
699+ < Streamdown
700+ key = { streamingTree ? 'stream' : 'settled' }
701+ mode = { parserTree ? undefined : 'static' }
702+ animated = { fadeActive ? STREAM_ANIMATION : false }
703+ isAnimating = { streamingTree }
704+ components = { MARKDOWN_COMPONENTS }
705+ >
706+ { group . markdown }
707+ </ Streamdown >
708+ </ div >
709+ )
710+ }
711+ return (
712+ < SpecialTags
713+ key = { `special-${ group . index } ` }
714+ segment = { group . segment }
715+ interactionId = { `${ messageId ?? 'message' } :${ group . index } ` }
716+ questionAnswers = { questionAnswers }
717+ credentialSubmission = { credentialSubmission }
718+ credentialAbandoned = { credentialAbandoned }
719+ onOptionSelect = { onOptionSelect }
720+ onQuestionDismiss = { onQuestionDismiss }
721+ />
642722 )
643- }
644- return (
645- < SpecialTags
646- key = { `special-${ group . index } ` }
647- segment = { group . segment }
648- interactionId = { `${ messageId ?? 'message' } :${ group . index } ` }
649- questionAnswers = { questionAnswers }
650- credentialSubmission = { credentialSubmission }
651- credentialAbandoned = { credentialAbandoned }
652- onOptionSelect = { onOptionSelect }
653- onQuestionDismiss = { onQuestionDismiss }
654- />
655- )
656- } ) }
657- </ div >
723+ } ) }
724+ </ div >
725+ </ SourceRefsContext . Provider >
658726 )
659727}
660728
0 commit comments