Skip to content

Commit 2604f08

Browse files
committed
feat(search): add Sim Search mode, connectors tab, and cited sources
- Build/Search mode switcher in the chat composer; Search mode swaps the suggested actions for Connect Sim Search rows that open the OAuth modal - Search tab beside Integrations and Skills: a personally scoped catalog of knowledge-base connectors with connect-in-place rows and a connected-credential page without sharing or members - <source> special tag contract: inline citation chips at the citation point plus a scrolling, right-faded sources strip under the reply - chip `shape` variant so a fully round chip is chip chrome, not a class override - keep the connector catalog out of the workflow editor's module graph and record the new routes in the boundary baseline
1 parent 78780c9 commit 2604f08

48 files changed

Lines changed: 2170 additions & 135 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: 97 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,33 @@ 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 `[label](#src-N)` so it flows
132+
* with its paragraph, and the link renderer resolves `N` back through this
133+
* context — the component map is static, so it is the one channel from segment
134+
* data into it.
135+
*/
136+
const SourceRefsContext = createContext<readonly SourceTagData[]>([])
137+
138+
const SOURCE_LINK_PREFIX = '#src-'
139+
140+
interface SourceReferenceProps {
141+
index: number
142+
children?: React.ReactNode
143+
}
144+
145+
/** The inline citation chip; a dangling index falls back to the link text. */
146+
function SourceReference({ index, children }: SourceReferenceProps) {
147+
const source = useContext(SourceRefsContext)[index]
148+
if (!source) return <>{children}</>
149+
return <SourceChip source={source} />
150+
}
151+
114152
function appendInlineReferenceMarkdown(
115153
currentMarkdown: string,
116154
referenceMarkdown: string,
@@ -263,6 +301,13 @@ const MARKDOWN_COMPONENTS = {
263301
)
264302
},
265303
a({ children, href }: { children?: React.ReactNode; href?: string }) {
304+
if (href?.startsWith(SOURCE_LINK_PREFIX)) {
305+
return (
306+
<SourceReference index={Number(href.slice(SOURCE_LINK_PREFIX.length))}>
307+
{children}
308+
</SourceReference>
309+
)
310+
}
266311
if (href?.startsWith('#wsres-')) {
267312
const match = href.match(/^#wsres-(\w+)-(.+)$/)
268313
const type = match?.[1]
@@ -566,14 +611,20 @@ function ChatContentInner({
566611

567612
type BlockSegment = Exclude<
568613
ContentSegment,
569-
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' }
614+
{ type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } | { type: 'source' }
570615
>
571616
type RenderGroup =
572617
| { kind: 'inline'; markdown: string }
573618
| { kind: 'block'; segment: BlockSegment; index: number }
574619

620+
const sourceRefs = useMemo(
621+
() => parsed.segments.flatMap((segment) => (segment.type === 'source' ? [segment.data] : [])),
622+
[parsed]
623+
)
624+
575625
const groups: RenderGroup[] = []
576626
let pendingMarkdown = ''
627+
let sourceIndex = 0
577628

578629
const flushMarkdown = () => {
579630
if (pendingMarkdown.trim()) {
@@ -596,6 +647,16 @@ function ChatContentInner({
596647
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
597648
nextSegment
598649
)
650+
} else if (s.type === 'source') {
651+
// A citation always stands off from the sentence it supports, even when
652+
// the model closes the sentence on punctuation the word-boundary rule
653+
// would otherwise glue the chip to.
654+
if (pendingMarkdown && !/\s$/.test(pendingMarkdown)) pendingMarkdown += ' '
655+
pendingMarkdown = appendInlineReferenceMarkdown(
656+
pendingMarkdown,
657+
`[${sourceLabel(s.data)}](<${SOURCE_LINK_PREFIX}${sourceIndex++}>)`,
658+
nextSegment
659+
)
599660
} else if (s.type === 'thinking') {
600661
// Model-emitted <thinking> tag bodies are reasoning, not answer text —
601662
// never rendered (matches the block-level thinking omission in
@@ -621,40 +682,42 @@ function ChatContentInner({
621682
* the new special block mounts.
622683
*/
623684
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}
685+
<SourceRefsContext.Provider value={sourceRefs}>
686+
<div className='space-y-3'>
687+
{groups.map((group, i) => {
688+
if (group.kind === 'inline') {
689+
return (
690+
<div
691+
key={`inline-${i}`}
692+
className={cn(PROSE_CLASSES, '[&>:first-child]:mt-0 [&>:last-child]:mb-0')}
638693
>
639-
{group.markdown}
640-
</Streamdown>
641-
</div>
694+
<Streamdown
695+
key={streamingTree ? 'stream' : 'settled'}
696+
mode={parserTree ? undefined : 'static'}
697+
animated={fadeActive ? STREAM_ANIMATION : false}
698+
isAnimating={streamingTree}
699+
components={MARKDOWN_COMPONENTS}
700+
>
701+
{group.markdown}
702+
</Streamdown>
703+
</div>
704+
)
705+
}
706+
return (
707+
<SpecialTags
708+
key={`special-${group.index}`}
709+
segment={group.segment}
710+
interactionId={`${messageId ?? 'message'}:${group.index}`}
711+
questionAnswers={questionAnswers}
712+
credentialSubmission={credentialSubmission}
713+
credentialAbandoned={credentialAbandoned}
714+
onOptionSelect={onOptionSelect}
715+
onQuestionDismiss={onQuestionDismiss}
716+
/>
642717
)
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>
718+
})}
719+
</div>
720+
</SourceRefsContext.Provider>
658721
)
659722
}
660723

‎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)