Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import { ChipLink, cn } from '@sim/emcn'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'

interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
active: 'integrations' | 'skills' | 'search'
workspaceId: string
/** Trailing actions for the owning page (e.g. skills' "Add skill"). */
rightSlot?: ReactNode
}

/**
* Top-of-page tab header shared by the Integrations and Skills pages — two halves
* of one surface, so each highlights itself and links to its sibling.
* Top-of-page tab header shared by the Integrations, Skills, and Search pages —
* three views of one surface, so each highlights itself and links to its siblings.
*
* Lives in the shared workspace components rather than under `integrations/`
* because both pages own it equally; its former home made Skills reach across into
* a sibling feature for its own chrome.
* because every page owns it equally; its former home made Skills reach across
* into a sibling feature for its own chrome.
*
* The `gap-1` is explicit because chips carry no outer margin — the parent owns the
* space between them.
Expand All @@ -33,6 +33,9 @@ export function IntegrationTabsHeader({
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
Skills
</ChipLink>
<ChipLink href={`/workspace/${workspaceId}/search`} active={active === 'search'}>
Search
</ChipLink>
{rightSlot && <div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>{rightSlot}</div>}
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,4 @@ export {
useMothershipResources,
} from './mothership-resources-context'
export { QueuedMessages } from './queued-messages'
export { SuggestedActions } from './suggested-actions'
export { UserInput, type UserInputHandle } from './user-input'
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ describe('sanitizeChatDisplayContent', () => {
)
})

it('unwraps source tags from inline code spans', () => {
const content = '`Block them first. <source>{"url":"https://docs.github.com/a"}</source>`'

expect(sanitizeChatDisplayContent(content)).toBe(
'Block them first. <source>{"url":"https://docs.github.com/a"}</source>'
)
})

it('removes hidden internal references wrapped in inline code', () => {
const content = 'Read `internal/tool-results/read-1.md` and found the issue.'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
'use client'

import { type ComponentPropsWithoutRef, memo, useEffect, useMemo, useRef, useState } from 'react'
import {
type ComponentPropsWithoutRef,
createContext,
memo,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
// prismjs core must load before its language components — they register on the
Expand All @@ -15,10 +24,15 @@ import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight }
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
SourceChip,
sourceLabel,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
import {
type ContentSegment,
type CredentialSubmissionPayload,
parseSpecialTags,
type SourceTagData,
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import type {
Expand Down Expand Up @@ -108,9 +122,38 @@ function nextInlineSegmentLabel(segment?: ContentSegment): string {
// Thinking segments are never rendered, so they contribute no following text.
if (segment.type === 'text') return segment.content
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
if (segment.type === 'source') return sourceLabel(segment.data)
return ''
}

/**
* The `<source>` payloads of the segment being rendered, in emission order. An
* inline citation is written into the markdown as a link to a sentinel
* fragment carrying the payload's index, so it flows with its paragraph, and
* the link renderer resolves the index back through this context — the
* component map is static, so it is the one channel from segment data into it.
*/
const SourceRefsContext = createContext<readonly SourceTagData[]>([])

/**
* Fragment prefix of a generated citation link. Internal — never navigated —
* and deliberately not a name the model would write on its own; an index that
* resolves to no parsed source falls back to the link text.
*/
const SOURCE_LINK_PREFIX = '#sim-source-ref-'

interface SourceReferenceProps {
index: number
children?: React.ReactNode
}

/** The inline citation chip; a dangling index falls back to the link text. */
function SourceReference({ index, children }: SourceReferenceProps) {
const source = useContext(SourceRefsContext)[index]
if (!source) return <>{children}</>
return <SourceChip source={source} />
}

function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
Expand Down Expand Up @@ -263,6 +306,13 @@ const MARKDOWN_COMPONENTS = {
)
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
if (href?.startsWith(SOURCE_LINK_PREFIX)) {
Comment thread
emir-karabeg marked this conversation as resolved.
return (
<SourceReference index={Number(href.slice(SOURCE_LINK_PREFIX.length))}>
{children}
</SourceReference>
)
}
if (href?.startsWith('#wsres-')) {
const match = href.match(/^#wsres-(\w+)-(.+)$/)
const type = match?.[1]
Expand Down Expand Up @@ -566,14 +616,20 @@ function ChatContentInner({

type BlockSegment = Exclude<
ContentSegment,
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } | { type: 'source' }
>
type RenderGroup =
| { kind: 'inline'; markdown: string }
| { kind: 'block'; segment: BlockSegment; index: number }

const sourceRefs = useMemo(
() => parsed.segments.flatMap((segment) => (segment.type === 'source' ? [segment.data] : [])),
[parsed]
)

const groups: RenderGroup[] = []
let pendingMarkdown = ''
let sourceIndex = 0

const flushMarkdown = () => {
if (pendingMarkdown.trim()) {
Expand All @@ -596,6 +652,16 @@ function ChatContentInner({
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
nextSegment
)
} else if (s.type === 'source') {
// A citation always stands off from the sentence it supports, even when
// the model closes the sentence on punctuation the word-boundary rule
// would otherwise glue the chip to.
if (pendingMarkdown && !/\s$/.test(pendingMarkdown)) pendingMarkdown += ' '
pendingMarkdown = appendInlineReferenceMarkdown(
pendingMarkdown,
`[${sourceLabel(s.data)}](<${SOURCE_LINK_PREFIX}${sourceIndex++}>)`,
nextSegment
)
} else if (s.type === 'thinking') {
// Model-emitted <thinking> tag bodies are reasoning, not answer text —
// never rendered (matches the block-level thinking omission in
Expand All @@ -621,40 +687,42 @@ function ChatContentInner({
* the new special block mounts.
*/
return (
<div className='space-y-3'>
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
<div
key={`inline-${i}`}
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
<Streamdown
key={streamingTree ? 'stream' : 'settled'}
mode={parserTree ? undefined : 'static'}
animated={fadeActive ? STREAM_ANIMATION : false}
isAnimating={streamingTree}
components={MARKDOWN_COMPONENTS}
<SourceRefsContext.Provider value={sourceRefs}>
<div className='space-y-3'>
{groups.map((group, i) => {
if (group.kind === 'inline') {
return (
<div
key={`inline-${i}`}
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
>
{group.markdown}
</Streamdown>
</div>
<Streamdown
key={streamingTree ? 'stream' : 'settled'}
mode={parserTree ? undefined : 'static'}
animated={fadeActive ? STREAM_ANIMATION : false}
isAnimating={streamingTree}
components={MARKDOWN_COMPONENTS}
>
{group.markdown}
</Streamdown>
</div>
)
}
return (
<SpecialTags
key={`special-${group.index}`}
segment={group.segment}
interactionId={`${messageId ?? 'message'}:${group.index}`}
questionAnswers={questionAnswers}
credentialSubmission={credentialSubmission}
credentialAbandoned={credentialAbandoned}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
/>
)
}
return (
<SpecialTags
key={`special-${group.index}`}
segment={group.segment}
interactionId={`${messageId ?? 'message'}:${group.index}`}
questionAnswers={questionAnswers}
credentialSubmission={credentialSubmission}
credentialAbandoned={credentialAbandoned}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
/>
)
})}
</div>
})}
</div>
</SourceRefsContext.Provider>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ const HIDDEN_INLINE_REFERENCE_PATTERN =
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g

/**
* A complete workspace-resource tag: opener, payload, closer.
* A complete inline-chip tag — `<workspace_resource>` or `<source>` — as
* opener, payload, closer. Both are JSON-bodied tags the model places inside a
* sentence, so both attract the same stray backticks.
*
* Two constraints on the payload, both load-bearing:
*
Expand All @@ -19,10 +21,10 @@ const HIDDEN_INLINE_REFERENCE_PATTERN =
* is rare; the failure it replaces corrupts a whole message and is common.
*/
const COMPLETE_TAG_SOURCE =
'<workspace_resource>(?:(?!<workspace_resource>)[^`])*?<\\/workspace_resource>'
'<(?<chipTag>workspace_resource|source)>(?:(?!<\\k<chipTag>>)[^`])*?<\\/\\k<chipTag>>'

/** Non-global so {@link RegExp.test} has no `lastIndex` to carry between calls. */
const COMPLETE_WORKSPACE_RESOURCE_TAG = new RegExp(COMPLETE_TAG_SOURCE)
const COMPLETE_INLINE_CHIP_TAG = new RegExp(COMPLETE_TAG_SOURCE)

/**
* One left-to-right pass over the two things that can own a backtick: an inline
Expand Down Expand Up @@ -57,7 +59,7 @@ export function sanitizeChatDisplayContent(content: string): string {
// lifts the tag out either way, so leaving the delimiters would strand a
// pair of backticks around a hole. Anything else is someone else's span.
const inner = match.slice(1, -1)
return COMPLETE_WORKSPACE_RESOURCE_TAG.test(inner) ? inner : match
return COMPLETE_INLINE_CHIP_TAG.test(inner) ? inner : match
})
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { faviconUrl } from '@/lib/core/utils/favicon'
import { useLinkPreview } from '@/hooks/queries/link-preview'

/** Hides a favicon img that failed to load so the link degrades to plain text. */
function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
export function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
e.currentTarget.style.display = 'none'
}

Expand Down Expand Up @@ -44,7 +44,10 @@ interface ExternalLinkProps {
* which the shell routes to the system browser. In a web browser this is a
* no-op and the link opens a new tab as usual.
*/
function handleLinkClick(event: React.MouseEvent<HTMLAnchorElement>, href: string): void {
export function handleExternalLinkClick(
event: React.MouseEvent<HTMLAnchorElement>,
href: string
): void {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
if (!shouldOpenInBrowserPanel(href)) return
event.preventDefault()
Expand All @@ -63,7 +66,7 @@ export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
className='not-prose group text-[var(--text-primary)] no-underline'
target='_blank'
rel='noopener noreferrer'
onClick={(event) => handleLinkClick(event, href)}
onClick={(event) => handleExternalLinkClick(event, href)}
>
<img
src={faviconUrl(hostname, 32)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
export { ChatContent } from './chat-content'
export { MessageSources } from './message-sources'
export { Options } from './options'
export { QuestionDisplay } from './question'
export { SourceChip, sourceLabel } from './source-chip'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MessageSources } from './message-sources'
Loading
Loading