Skip to content

Commit 27f03fe

Browse files
committed
merge: feat/search (#7376) into feat/permission-aware-knowledge
2 parents d790074 + 3c319f0 commit 27f03fe

49 files changed

Lines changed: 2504 additions & 321 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import { ChipLink, cn } from '@sim/emcn'
33
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
44

55
interface IntegrationTabsHeaderProps {
6-
active: 'integrations' | 'skills'
6+
active: 'integrations' | 'skills' | 'search'
77
workspaceId: string
88
/** Trailing actions for the owning page (e.g. skills' "Add skill"). */
99
rightSlot?: ReactNode
1010
}
1111

1212
/**
13-
* Top-of-page tab header shared by the Integrations and Skills pages — two halves
14-
* of one surface, so each highlights itself and links to its sibling.
13+
* Top-of-page tab header shared by the Integrations, Skills, and Search pages —
14+
* three views of one surface, so each highlights itself and links to its siblings.
1515
*
1616
* Lives in the shared workspace components rather than under `integrations/`
17-
* because both pages own it equally; its former home made Skills reach across into
18-
* a sibling feature for its own chrome.
17+
* because every page owns it equally; its former home made Skills reach across
18+
* into a sibling feature for its own chrome.
1919
*
2020
* The `gap-1` is explicit because chips carry no outer margin — the parent owns the
2121
* space between them.
@@ -33,6 +33,9 @@ export function IntegrationTabsHeader({
3333
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
3434
Skills
3535
</ChipLink>
36+
<ChipLink href={`/workspace/${workspaceId}/search`} active={active === 'search'}>
37+
Search
38+
</ChipLink>
3639
{rightSlot && <div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>{rightSlot}</div>}
3740
</div>
3841
)

apps/sim/app/workspace/[workspaceId]/home/components/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,4 @@ export {
1212
useMothershipResources,
1313
} from './mothership-resources-context'
1414
export { QueuedMessages } from './queued-messages'
15-
export { SuggestedActions } from './suggested-actions'
1615
export { UserInput, type UserInputHandle } from './user-input'

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ describe('sanitizeChatDisplayContent', () => {
1515
)
1616
})
1717

18+
it('unwraps source tags from inline code spans', () => {
19+
const content = '`Block them first. <source>{"url":"https://docs.github.com/a"}</source>`'
20+
21+
expect(sanitizeChatDisplayContent(content)).toBe(
22+
'Block them first. <source>{"url":"https://docs.github.com/a"}</source>'
23+
)
24+
})
25+
1826
it('removes hidden internal references wrapped in inline code', () => {
1927
const content = 'Read `internal/tool-results/read-1.md` and found the issue.'
2028

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 102 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
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'
413
import { Streamdown } from 'streamdown'
514
import '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 }
1524
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
1625
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1726
import { 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'
1831
import {
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'
2438
import 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+
114157
function 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(/^#wsres-(\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

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ const HIDDEN_INLINE_REFERENCE_PATTERN =
22
/`[^`\n]*(?:internal\/tool-results\/|internal\/blocktips\/|components\/integrations\/[^`\n]*README)[^`\n]*`/g
33

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

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

2729
/**
2830
* One left-to-right pass over the two things that can own a backtick: an inline
@@ -57,7 +59,7 @@ export function sanitizeChatDisplayContent(content: string): string {
5759
// lifts the tag out either way, so leaving the delimiters would strand a
5860
// pair of backticks around a hole. Anything else is someone else's span.
5961
const inner = match.slice(1, -1)
60-
return COMPLETE_WORKSPACE_RESOURCE_TAG.test(inner) ? inner : match
62+
return COMPLETE_INLINE_CHIP_TAG.test(inner) ? inner : match
6163
})
6264
.replace(HIDDEN_INLINE_REFERENCE_PATTERN, '')
6365
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { faviconUrl } from '@/lib/core/utils/favicon'
66
import { useLinkPreview } from '@/hooks/queries/link-preview'
77

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

@@ -44,7 +44,10 @@ interface ExternalLinkProps {
4444
* which the shell routes to the system browser. In a web browser this is a
4545
* no-op and the link opens a new tab as usual.
4646
*/
47-
function handleLinkClick(event: React.MouseEvent<HTMLAnchorElement>, href: string): void {
47+
export function handleExternalLinkClick(
48+
event: React.MouseEvent<HTMLAnchorElement>,
49+
href: string
50+
): void {
4851
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
4952
if (!shouldOpenInBrowserPanel(href)) return
5053
event.preventDefault()
@@ -63,7 +66,7 @@ export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
6366
className='not-prose group text-[var(--text-primary)] no-underline'
6467
target='_blank'
6568
rel='noopener noreferrer'
66-
onClick={(event) => handleLinkClick(event, href)}
69+
onClick={(event) => handleExternalLinkClick(event, href)}
6770
>
6871
<img
6972
src={faviconUrl(hostname, 32)}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
22
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
33
export { ChatContent } from './chat-content'
4+
export { MessageSources } from './message-sources'
45
export { Options } from './options'
56
export { QuestionDisplay } from './question'
7+
export { SourceChip, sourceLabel } from './source-chip'
68
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MessageSources } from './message-sources'

0 commit comments

Comments
 (0)