diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
index 53efc5a0175..60863ec586f 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx
@@ -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.
@@ -33,6 +33,9 @@ export function IntegrationTabsHeader({
Skills
+
+ Search
+
{rightSlot &&
{rightSlot}
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
index 8425c127a6e..fb354716ea3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts
index 1b3e5d105d9..eb9f86f6035 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.test.ts
@@ -15,6 +15,14 @@ describe('sanitizeChatDisplayContent', () => {
)
})
+ it('unwraps source tags from inline code spans', () => {
+ const content = '`Block them first. {"url":"https://docs.github.com/a"}`'
+
+ expect(sanitizeChatDisplayContent(content)).toBe(
+ 'Block them first. {"url":"https://docs.github.com/a"}'
+ )
+ })
+
it('removes hidden internal references wrapped in inline code', () => {
const content = 'Read `internal/tool-results/read-1.md` and found the issue.'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index c143c72bb4a..03ccd3c54e0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -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
@@ -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 {
@@ -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 `` 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([])
+
+/**
+ * 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
+}
+
function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
@@ -263,6 +306,13 @@ const MARKDOWN_COMPONENTS = {
)
},
a({ children, href }: { children?: React.ReactNode; href?: string }) {
+ if (href?.startsWith(SOURCE_LINK_PREFIX)) {
+ return (
+
+ {children}
+
+ )
+ }
if (href?.startsWith('#wsres-')) {
const match = href.match(/^#wsres-(\w+)-(.+)$/)
const type = match?.[1]
@@ -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()) {
@@ -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 tag bodies are reasoning, not answer text —
// never rendered (matches the block-level thinking omission in
@@ -621,40 +687,42 @@ function ChatContentInner({
* the new special block mounts.
*/
return (
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts
index 4e0792df0ab..1d210bd1b92 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize.ts
@@ -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 — `` or `` — 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:
*
@@ -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|source)>(?:(?!<\\k>)[^`])*?<\\/\\k>'
/** 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
@@ -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, '')
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
index 405acc7a9d4..fd9661d2151 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link.tsx
@@ -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): void {
+export function hideBrokenFavicon(e: React.SyntheticEvent): void {
e.currentTarget.style.display = 'none'
}
@@ -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, href: string): void {
+export function handleExternalLinkClick(
+ event: React.MouseEvent,
+ href: string
+): void {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
if (!shouldOpenInBrowserPanel(href)) return
event.preventDefault()
@@ -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)}
>
({
+ shouldOpenInBrowserPanel: () => false,
+ openInBrowserPanel: vi.fn(),
+}))
+vi.mock('@/lib/integrations', () => ({
+ blockTypeToIconMap: { confluence_v2: () => },
+}))
+
+import { MessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function mount(ui: React.ReactNode) {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root?.render(ui))
+}
+
+function chips(): HTMLAnchorElement[] {
+ return Array.from(container?.querySelectorAll('a') ?? [])
+}
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+})
+
+describe('MessageSources', () => {
+ it('renders one chip per source with the site name or hostname as its label', () => {
+ mount(
+
+ )
+
+ expect(chips().map((chip) => chip.textContent)).toEqual(['GitHub Docs', 'example.com'])
+ expect(chips().map((chip) => chip.getAttribute('href'))).toEqual([
+ 'https://docs.github.com/en/a',
+ 'https://www.example.com/page',
+ ])
+ expect(chips()[0].getAttribute('target')).toBe('_blank')
+ expect(chips()[0].className).toContain('rounded-full')
+ })
+
+ it('shows the connector brand mark when the source names a connector, else the favicon', () => {
+ mount(
+
+ )
+
+ const [connector, favicon] = chips()
+ expect(connector.querySelector('svg[data-brand="confluence"]')).not.toBeNull()
+ expect(connector.querySelector('img')).toBeNull()
+ expect(favicon.querySelector('img')?.getAttribute('src')).toContain('docs.github.com')
+ })
+
+ it('renders nothing without sources', () => {
+ mount()
+
+ expect(container?.childElementCount).toBe(0)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
new file mode 100644
index 00000000000..8c72089296e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { cn } from '@sim/emcn'
+import { SourceChip } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+
+/**
+ * Right-edge fade so an overflowing strip reads as scrollable rather than cut.
+ * The strip's trailing padding matches the fade width, so the last chip scrolls
+ * fully clear of it.
+ */
+const STRIP_FADE_CLASSES =
+ 'pr-10 [-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_40px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_40px),transparent)]'
+
+interface MessageSourcesProps {
+ sources: readonly SourceTagData[]
+}
+
+/**
+ * Footer strip listing every document a reply cited, once each: one
+ * horizontally scrolling row of {@link SourceChip}s that fades out at the right
+ * edge instead of wrapping, so a long list stays a single quiet line under the
+ * answer.
+ */
+export function MessageSources({ sources }: MessageSourcesProps) {
+ if (sources.length === 0) return null
+
+ return (
+
+ {sources.map((source) => (
+
+ ))}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts
new file mode 100644
index 00000000000..4168fe2a584
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts
@@ -0,0 +1 @@
+export { SourceChip, sourceLabel } from './source-chip'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx
new file mode 100644
index 00000000000..4101ddffb17
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import { chipFilledFillTokens, chipHoverSurfaceClass, cn, OverflowText, Tooltip } from '@sim/emcn'
+import { stripVersionSuffix } from '@sim/utils/string'
+import { faviconUrl } from '@/lib/core/utils/favicon'
+import { blockTypeToIconMap } from '@/lib/integrations'
+import {
+ externalLinkHostname,
+ handleExternalLinkClick,
+ hideBrokenFavicon,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
+import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon'
+
+/**
+ * Brand marks by base block type. A connector id names the same product as its
+ * integration block (`confluence`, `google_drive`), so the block's mark serves
+ * the chip — through the catalog icon map rather than the connector registry,
+ * which would drag seventy connector modules into every surface that renders
+ * chat. Versioned catalog types (`gmail_v2`) collapse onto their base name.
+ */
+const BRAND_ICON_BY_BASE_TYPE: ReadonlyMap = new Map(
+ Object.entries(blockTypeToIconMap).map(([type, icon]) => [stripVersionSuffix(type), icon])
+)
+
+/** Chip label: the site name the model supplied, else the URL's hostname without a `www.` prefix. */
+export function sourceLabel(source: SourceTagData): string {
+ const siteName = source.siteName?.trim()
+ if (siteName) return siteName
+ return (externalLinkHostname(source.url) ?? source.url).replace(/^www\./, '')
+}
+
+interface SourceChipProps {
+ source: SourceTagData
+}
+
+/**
+ * A cited document as a small round pill — the connector's brand mark or the
+ * site favicon, then the site name — used inline at the citation point and
+ * again in the footer strip. Built on the chip fill and hover tokens at a 20px
+ * height so it sits inside a line of prose; the 30px `Chip` is the wrong scale
+ * for a citation. Opens the document like any external link in the reply.
+ */
+export function SourceChip({ source }: SourceChipProps) {
+ const hostname = externalLinkHostname(source.url)
+ const ConnectorIcon = source.connectorType
+ ? BRAND_ICON_BY_BASE_TYPE.get(source.connectorType)
+ : undefined
+
+ return (
+
+
+ handleExternalLinkClick(event, source.url)}
+ className={cn(
+ 'not-prose inline-flex h-[20px] max-w-[220px] shrink-0 items-center gap-1 rounded-full px-1.5 align-middle text-[var(--text-body)] text-caption no-underline transition-colors',
+ chipFilledFillTokens,
+ chipHoverSurfaceClass
+ )}
+ >
+ {ConnectorIcon ? (
+
+ ) : hostname ? (
+
+ ) : null}
+
+
+
+
+ {source.title ? (
+
+ {source.title}
+ {source.url}
+
+ ) : (
+ {source.url}
+ )}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
index 1603a0278c5..8964244b4eb 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts
@@ -13,6 +13,7 @@ export type {
QuestionTagData,
QuestionType,
RuntimeSpecialTagName,
+ SourceTagData,
UsageUpgradeAction,
UsageUpgradeTagData,
WorkspaceResourceTagData,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
index 5063faecdba..3dab938af28 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts
@@ -1073,6 +1073,7 @@ describe('parser properties', () => {
'{"reason":"monthly cap","action":"upgrade_plan","message":"You hit your limit."}',
'mothership-error':
'{"message":"The tool call failed.","code":"E_TOOL"}',
+ source: '{"url":"https://docs.github.com/en/x","siteName":"GitHub Docs"}',
}
/** Renders nothing rather than a card, so it cannot carry a card invariant. */
@@ -1374,3 +1375,52 @@ describe('ordinary JSON in prose is never turned into a card', () => {
expect(segments.some((s) => s.type === 'text')).toBe(true)
})
})
+
+describe('source tag', () => {
+ it('parses a complete source tag into a source segment', () => {
+ const { segments } = parseSpecialTags(
+ 'Remove them first. {"url":"https://docs.github.com/en/x","siteName":"GitHub Docs","title":"Blocking users"} Then block.',
+ false
+ )
+
+ expect(segments).toEqual([
+ { type: 'text', content: 'Remove them first. ' },
+ {
+ type: 'source',
+ data: {
+ url: 'https://docs.github.com/en/x',
+ siteName: 'GitHub Docs',
+ title: 'Blocking users',
+ },
+ },
+ { type: 'text', content: ' Then block.' },
+ ])
+ })
+
+ it('keeps adjacent source tags as separate segments', () => {
+ const { segments } = parseSpecialTags(
+ 'Done. {"url":"https://a.example/1"}{"url":"https://b.example/2"}',
+ false
+ )
+
+ expect(segments.filter((segment) => segment.type === 'source')).toHaveLength(2)
+ })
+
+ it('rejects a source without an absolute http(s) url', () => {
+ for (const url of ['docs/internal.md', 'https://?', 'ftp://host/x', 'https://a b.example/x']) {
+ const { segments } = parseSpecialTags(
+ `See {"url":"${url}","siteName":"Docs"}.`,
+ false
+ )
+
+ expect(segments.some((segment) => segment.type === 'source')).toBe(false)
+ }
+ })
+
+ it('hides a half-arrived source opener while streaming', () => {
+ const { segments, hasPendingTag } = parseSpecialTags('Block them. ` tag: one document the reply drew on. The tag contract for
+ * search answers — the model emits it inline, right after the sentence, list
+ * item, or paragraph the document supports, as a JSON body:
+ *
+ * `{"url":"https://docs.github.com/…","siteName":"GitHub Docs"}`
+ *
+ * Each tag renders as its own small chip where it sits (adjacent tags are
+ * never collapsed into a count), and every distinct `url` in the message is
+ * repeated in the footer strip below the reply.
+ */
+export interface SourceTagData {
+ /** Canonical http(s) link to the referenced document. */
+ url: string
+ /** Document title, shown on hover. */
+ title?: string
+ /**
+ * Short chip label — the site or product the document lives in ("GitHub
+ * Docs", "Confluence"). Falls back to the URL's hostname.
+ */
+ siteName?: string
+ /**
+ * Knowledge-base connector the document was synced through
+ * (`CONNECTOR_META_REGISTRY` key). Lends the chip the product's brand mark;
+ * without it the chip shows the site favicon.
+ */
+ connectorType?: string
+}
+
export type ContentSegment =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
@@ -325,6 +354,7 @@ export type ContentSegment =
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
| { type: 'question'; data: QuestionTagData }
+ | { type: 'source'; data: SourceTagData }
export type RuntimeSpecialTagName =
| 'thinking'
@@ -334,6 +364,7 @@ export type RuntimeSpecialTagName =
| 'file'
| 'workspace_resource'
| 'question'
+ | 'source'
export interface ParsedSpecialContent {
segments: ContentSegment[]
@@ -348,6 +379,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [
'file',
'workspace_resource',
'question',
+ 'source',
] as const
/**
@@ -363,6 +395,7 @@ export const SPECIAL_TAG_NAMES = [
'mothership-error',
'workspace_resource',
'question',
+ 'source',
] as const
function isOptionsItemData(value: unknown): value is OptionsItemData {
@@ -523,6 +556,30 @@ function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagDa
)
}
+/**
+ * Only an absolute http(s) URL with a host can be linked; anything else is not
+ * a source. Parsed rather than pattern-matched so a malformed value such as
+ * `https://?` — which a prefix check would accept — never becomes a dead link.
+ */
+function isHttpUrl(value: unknown): value is string {
+ if (typeof value !== 'string' || /\s/.test(value)) return false
+ try {
+ const url = new URL(value)
+ return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0
+ } catch {
+ return false
+ }
+}
+
+function isSourceTagData(value: unknown): value is SourceTagData {
+ if (!isRecordLike(value)) return false
+ if (!isHttpUrl(value.url)) return false
+ if (value.title !== undefined && typeof value.title !== 'string') return false
+ if (value.siteName !== undefined && typeof value.siteName !== 'string') return false
+ if (value.connectorType !== undefined && typeof value.connectorType !== 'string') return false
+ return true
+}
+
function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceTagData {
if (!isRecordLike(value)) return false
if (
@@ -713,6 +770,7 @@ function parseSpecialTagData(
| { type: 'mothership-error'; data: MothershipErrorTagData }
| { type: 'workspace_resource'; data: WorkspaceResourceTagData }
| { type: 'question'; data: QuestionTagData }
+ | { type: 'source'; data: SourceTagData }
| null {
if (tagName === 'thinking') {
const content = parseTextTagBody(body)
@@ -744,6 +802,11 @@ function parseSpecialTagData(
return data ? { type: 'workspace_resource', data } : null
}
+ if (tagName === 'source') {
+ const data = parseJsonTagBody(body, isSourceTagData)
+ return data ? { type: 'source', data } : null
+ }
+
if (tagName === 'question') {
const data = parseQuestionTagBody(body)
if (data) return { type: 'question', data }
@@ -1659,7 +1722,8 @@ interface SpecialTagsProps {
/**
* Unified renderer for inline special tags: ``, ``, ``,
- * and ``.
+ * and ``. A `` never reaches here — the chat renderer
+ * folds it into the surrounding markdown as an inline chip.
*/
export function SpecialTags({
segment,
@@ -1692,6 +1756,8 @@ export function SpecialTags({
return
case 'workspace_resource':
return
+ case 'source':
+ return null
case 'question':
return (
0
- ? parsed
- : fallbackContent?.trim()
- ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }]
- : []
+ const segments = useMemo(
+ () =>
+ parsed.length > 0
+ ? parsed
+ : fallbackContent?.trim()
+ ? [{ type: 'text', id: 'text-fallback', content: fallbackContent }]
+ : [],
+ [parsed, fallbackContent]
+ )
+ /**
+ * Collected from the segments that render, not the raw blocks: that is the
+ * same text the inline chips come from, so the footer agrees with them — it
+ * covers the fallback text of a block-less message and leaves out lane text
+ * that `parseBlocks` folds into agent groups.
+ */
+ const sources = useMemo(
+ () =>
+ collectMessageSources(
+ segments.flatMap((segment) => (segment.type === 'text' ? [segment.content] : []))
+ ),
+ [segments]
+ )
const visibleStreamActivityKey = getVisibleStreamActivityKey(segments)
// Every visible stream update restarts the quiet-period clock. A layout
@@ -1004,6 +1027,11 @@ function MessageContentInner({
return null
}
})}
+ {sources.length > 0 && (
+
+
+
+ )}
{thinkingExpanded && isLast ? (
// Fixed-height placeholder for the NEXT piece of output: the shimmer
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts
index f0a74e539a7..de5bd6402fe 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
-import { deriveMessagePhase, resolveToolDisplayState } from './utils'
+import { collectMessageSources, deriveMessagePhase, resolveToolDisplayState } from './utils'
describe('deriveMessagePhase', () => {
it('is streaming whenever the transport is live', () => {
@@ -36,3 +36,25 @@ describe('resolveToolDisplayState', () => {
expect(resolveToolDisplayState('rejected')).toBe('icon')
})
})
+
+describe('collectMessageSources', () => {
+ const source = (url: string, extra = '') => `{"url":"${url}"${extra}}`
+
+ it('collects every distinct source across the given text, in first-cited order', () => {
+ const texts = [
+ `First point. ${source('https://a.example/1', ',"siteName":"A"')} Second. ${source('https://b.example/2')}`,
+ `Again. ${source('https://a.example/1')} New. ${source('https://c.example/3')}`,
+ ]
+
+ expect(collectMessageSources(texts).map((entry) => entry.url)).toEqual([
+ 'https://a.example/1',
+ 'https://b.example/2',
+ 'https://c.example/3',
+ ])
+ expect(collectMessageSources(texts)[0].siteName).toBe('A')
+ })
+
+ it('returns nothing for prose without sources', () => {
+ expect(collectMessageSources(['Plain prose.', ''])).toEqual([])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
index 64176c822c5..166f5fd01c5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
@@ -21,10 +21,31 @@ import {
} from '@sim/emcn'
import { Calendar, Clock, Cursor, Globe, Table as TableIcon } from '@sim/emcn/icons'
import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons'
+import {
+ parseSpecialTags,
+ type SourceTagData,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import type { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
export type IconComponent = ComponentType>
+/**
+ * Every distinct `` cited across the given prose, in first-cited order,
+ * for the footer strip. Callers pass the text segments the message actually
+ * renders as its answer.
+ */
+export function collectMessageSources(texts: readonly string[]): SourceTagData[] {
+ const byUrl = new Map()
+ for (const text of texts) {
+ for (const segment of parseSpecialTags(text, false).segments) {
+ if (segment.type === 'source' && !byUrl.has(segment.data.url)) {
+ byUrl.set(segment.data.url, segment.data)
+ }
+ }
+ }
+ return [...byUrl.values()]
+}
+
const TOOL_ICONS: Record = {
mothership: Blimp,
glob: FolderCode,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.test.ts
new file mode 100644
index 00000000000..e1290578c36
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.test.ts
@@ -0,0 +1,99 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRandomFloat } = vi.hoisted(() => ({
+ mockRandomFloat: vi.fn<() => number>(),
+}))
+
+vi.mock('@sim/utils/random', () => ({ randomFloat: mockRandomFloat }))
+
+vi.mock('@/lib/sim-search/connectors', () => {
+ const icon = () => null
+ const connector = (type: string, name: string, providerId: string) => ({
+ type,
+ meta: { id: type, name, description: `Sync ${name}`, icon },
+ providerId,
+ providerIds: [providerId],
+ requiredScopes: [],
+ serviceName: name,
+ serviceIcon: icon,
+ blockType: type,
+ })
+ return {
+ isSearchConnectorConnected: (
+ candidate: { providerIds: string[] },
+ connected: ReadonlySet
+ ) => candidate.providerIds.some((providerId) => connected.has(providerId)),
+ SEARCH_CONNECTORS: [
+ connector('airtable', 'Airtable', 'airtable'),
+ connector('confluence', 'Confluence', 'confluence'),
+ connector('jira', 'Jira', 'jira'),
+ connector('jsm', 'Jira Service Management', 'jira'),
+ connector('notion', 'Notion', 'notion'),
+ connector('slack', 'Slack', 'slack'),
+ ],
+ }
+})
+
+import { computeConnectorActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions'
+
+const ALL_AVAILABLE = () => true
+
+describe('computeConnectorActions', () => {
+ beforeEach(() => {
+ /** A zero roll always samples the first remaining candidate, so the rotation is catalog order. */
+ mockRandomFloat.mockReturnValue(0)
+ })
+
+ it('pins Confluence, Jira, and JSM first and fills the last slot from the rotation', () => {
+ const actions = computeConnectorActions(new Set(), ALL_AVAILABLE)
+
+ expect(actions.map((action) => action.id)).toEqual([
+ 'connect-confluence',
+ 'connect-jira',
+ 'connect-jsm',
+ 'connect-airtable',
+ ])
+ expect(actions[0]).toMatchObject({
+ kind: 'connector',
+ label: 'Connect Confluence',
+ target: { providerId: 'confluence', serviceName: 'Confluence' },
+ })
+ })
+
+ it('drops every connector on a connected provider and refills from the rotation', () => {
+ const actions = computeConnectorActions(new Set(['jira', 'airtable']), ALL_AVAILABLE)
+
+ expect(actions.map((action) => action.id)).toEqual([
+ 'connect-confluence',
+ 'connect-notion',
+ 'connect-slack',
+ ])
+ })
+
+ it('drops connectors this deployment cannot connect, pinned or not', () => {
+ const actions = computeConnectorActions(
+ new Set(),
+ (connector) => connector.type !== 'jira' && connector.type !== 'airtable'
+ )
+
+ expect(actions.map((action) => action.id)).toEqual([
+ 'connect-confluence',
+ 'connect-jsm',
+ 'connect-notion',
+ 'connect-slack',
+ ])
+ })
+
+ it('returns fewer than four rows once the rotation is exhausted', () => {
+ const actions = computeConnectorActions(new Set(['airtable', 'notion', 'slack']), ALL_AVAILABLE)
+
+ expect(actions.map((action) => action.id)).toEqual([
+ 'connect-confluence',
+ 'connect-jira',
+ 'connect-jsm',
+ ])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.ts
new file mode 100644
index 00000000000..dfb30c4765b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions.ts
@@ -0,0 +1,59 @@
+import {
+ isSearchConnectorConnected,
+ SEARCH_CONNECTORS,
+ type SearchConnector,
+} from '@/lib/sim-search/connectors'
+import type { Action } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
+import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
+
+/** Rows shown in Search mode — the same count as the Build-mode set. */
+const CONNECTOR_ACTION_COUNT = 4
+
+/**
+ * Connectors pinned to the head of the Search-mode list, in this order. Keys
+ * are `CONNECTOR_META_REGISTRY` keys, as on every {@link SearchConnector}.
+ */
+export const PINNED_CONNECTOR_TYPES = ['confluence', 'jira', 'jsm'] as const
+
+const PINNED_TYPES: ReadonlySet = new Set(PINNED_CONNECTOR_TYPES)
+
+/** The pinned connectors, in pinned order. */
+const PINNED: readonly SearchConnector[] = PINNED_CONNECTOR_TYPES.flatMap((type) => {
+ const connector = SEARCH_CONNECTORS.find((candidate) => candidate.type === type)
+ return connector ? [connector] : []
+})
+
+/** Every other Sim Search connector — the pool the remaining slots rotate through. */
+const ROTATING: readonly SearchConnector[] = SEARCH_CONNECTORS.filter(
+ (connector) => !PINNED_TYPES.has(connector.type)
+)
+
+function toConnectorAction(connector: SearchConnector): Action {
+ return {
+ kind: 'connector',
+ id: `connect-${connector.type}`,
+ label: `Connect ${connector.meta.name}`,
+ icon: connector.meta.icon,
+ target: connector,
+ }
+}
+
+/**
+ * Builds the Search-mode rows: the pinned connectors first, then a uniform
+ * sample of the rest to fill four slots. A connector whose provider the viewer
+ * has already connected is dropped from both halves — so Jira and Jira Service
+ * Management, which share one provider, leave together — and a pinned slot
+ * freed that way is taken by the rotation. Connectors this deployment cannot
+ * connect are dropped the same way, so a row never opens a modal that fails.
+ */
+export function computeConnectorActions(
+ connectedProviderIds: ReadonlySet,
+ isAvailable: (connector: SearchConnector) => boolean
+): Action[] {
+ const offered = (connector: SearchConnector) =>
+ isAvailable(connector) && !isSearchConnectorConnected(connector, connectedProviderIds)
+ const pinned = PINNED.filter(offered)
+ const pool = ROTATING.filter(offered)
+ const rotating = weightedSample(pool, CONNECTOR_ACTION_COUNT - pinned.length, () => 1)
+ return [...pinned, ...rotating].map(toConnectorAction)
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx
new file mode 100644
index 00000000000..f997520eaf3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.test.tsx
@@ -0,0 +1,170 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCaptureEvent, mockUseSearchCredentials } = vi.hoisted(() => ({
+ mockCaptureEvent: vi.fn(),
+ mockUseSearchCredentials: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+}))
+vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
+vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
+vi.mock('@sim/utils/random', () => ({ randomFloat: () => 0 }))
+
+vi.mock('@/hooks/queries/credentials', () => ({
+ useWorkspaceCredentials: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
+ useOAuthConnections: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/tables', () => ({
+ useTablesList: () => ({ data: [] }),
+}))
+vi.mock('@/hooks/queries/kb/knowledge', () => ({
+ useKnowledgeBasesQuery: () => ({ data: [] }),
+}))
+vi.mock('@/app/workspace/[workspaceId]/search/hooks/use-search-credentials', () => ({
+ useSearchCredentials: mockUseSearchCredentials,
+}))
+vi.mock('@/hooks/use-permission-config', () => ({
+ usePermissionConfig: () => ({
+ integrationAvailability: new Map([['notion', { state: 'unavailable', oauthAvailable: false }]]),
+ }),
+}))
+
+/** The Build-mode pool is built from the block catalog at module load; an empty catalog keeps it to the table starters. */
+vi.mock('@/blocks/registry', () => ({ getAllBlockMeta: () => ({}), getAllBlocks: () => [] }))
+
+vi.mock('@/lib/sim-search/connectors', () => {
+ const icon = () => null
+ const connector = (type: string, name: string, providerId: string) => ({
+ type,
+ meta: { id: type, name, description: `Sync ${name}`, icon },
+ providerId,
+ providerIds: [providerId],
+ requiredScopes: ['read'],
+ serviceName: name,
+ serviceIcon: icon,
+ blockType: type,
+ })
+ return {
+ isSearchConnectorConnected: (
+ candidate: { providerIds: string[] },
+ connected: ReadonlySet
+ ) => candidate.providerIds.some((providerId) => connected.has(providerId)),
+ isSearchConnectorAvailable: (
+ candidate: { blockType: string },
+ availability: ReadonlyMap
+ ) => availability.get(candidate.blockType)?.oauthAvailable ?? true,
+ SEARCH_CONNECTORS: [
+ connector('airtable', 'Airtable', 'airtable'),
+ connector('confluence', 'Confluence', 'confluence'),
+ connector('jira', 'Jira', 'jira'),
+ connector('jsm', 'Jira Service Management', 'jira'),
+ connector('notion', 'Notion', 'notion'),
+ connector('slack', 'Slack', 'slack'),
+ ],
+ }
+})
+
+vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({
+ ConnectOAuthModal: ({ open, providerId }: { open: boolean; providerId: string }) =>
+ open ?
{providerId}
: null,
+}))
+
+import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions'
+import { useMothershipModeStore } from '@/stores/mothership-mode/store'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+const onSelectPrompt = vi.fn()
+
+function mount() {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root?.render())
+}
+
+function heading(): string {
+ return container?.querySelector('button[aria-expanded] span')?.textContent ?? ''
+}
+
+function rows(): HTMLButtonElement[] {
+ return Array.from(
+ container?.querySelectorAll('button:not([aria-expanded])') ?? []
+ )
+}
+
+function connectModal(): string | null {
+ return document.querySelector('[data-testid="connect-modal"]')?.textContent ?? null
+}
+
+beforeEach(() => {
+ onSelectPrompt.mockClear()
+ mockCaptureEvent.mockClear()
+ mockUseSearchCredentials.mockReturnValue({
+ credentials: [{ id: 'cred-jira', providerId: 'jira' }],
+ isPending: false,
+ })
+ useMothershipModeStore.getState().reset()
+})
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+})
+
+describe('SuggestedActions', () => {
+ it('shows the Build starters by default', () => {
+ mount()
+
+ expect(heading()).toBe('Suggested actions')
+ expect(rows().map((row) => row.textContent)).toContain('Integrate with Slack')
+ })
+
+ it('swaps to the connector list in Search mode, minus connected and unavailable connectors', () => {
+ mount()
+
+ act(() => useMothershipModeStore.getState().setMode('search'))
+
+ expect(heading()).toBe('Connect Sim Search')
+ expect(rows().map((row) => row.textContent)).toEqual([
+ 'Connect Confluence',
+ 'Connect Airtable',
+ 'Connect Slack',
+ ])
+ })
+
+ it('opens the OAuth connect modal for a connector row instead of populating the input', () => {
+ mount()
+ act(() => useMothershipModeStore.getState().setMode('search'))
+ expect(connectModal()).toBeNull()
+
+ act(() => {
+ rows()[0].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
+ })
+
+ expect(connectModal()).toBe('confluence')
+ expect(onSelectPrompt).not.toHaveBeenCalled()
+ expect(mockCaptureEvent).toHaveBeenCalledWith(
+ null,
+ 'suggested_action_clicked',
+ expect.objectContaining({
+ kind: 'connector',
+ action_id: 'connect-confluence',
+ position: 0,
+ connected_provider_count: 1,
+ })
+ )
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
index 32f878c1a27..c1f47260b06 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -1,21 +1,28 @@
'use client'
-import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { Table } from '@sim/emcn/icons'
-import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { GmailIcon, SlackIcon } from '@/components/icons'
import {
INTEGRATIONS,
- type OAuthServiceMatch,
resolveOAuthServiceForIntegration,
resolveOAuthServiceForSlug,
} from '@/lib/integrations'
import { captureEvent } from '@/lib/posthog/client'
+import { isSearchConnectorAvailable } from '@/lib/sim-search/connectors'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
+import { computeConnectorActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/connector-actions'
+import type {
+ Action,
+ ActionIcon,
+ OAuthConnectTarget,
+} from '@/app/workspace/[workspaceId]/home/components/suggested-actions/types'
+import { weightedSample } from '@/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample'
+import { useSearchCredentials } from '@/app/workspace/[workspaceId]/search/hooks/use-search-credentials'
import { BrandIcon } from '@/blocks/brand-icon'
import { getAllBlockMeta } from '@/blocks/registry'
import type { ModuleTag } from '@/blocks/types'
@@ -23,12 +30,8 @@ import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
import { useTablesList } from '@/hooks/queries/tables'
-
-type Icon = ComponentType<{ className?: string; style?: CSSProperties }>
-
-type Action =
- | { kind: 'prompt'; id: string; label: string; prompt: string; icon: Icon }
- | { kind: 'integration'; id: string; label: string; icon: Icon; slug: string }
+import { usePermissionConfig } from '@/hooks/use-permission-config'
+import { type MothershipMode, useMothershipModeStore } from '@/stores/mothership-mode/store'
/** Lookup integration slug by OAuth service display name (case-insensitive). */
const SLUG_BY_LOWER_NAME: ReadonlyMap = new Map(
@@ -51,7 +54,7 @@ interface Candidate {
blockType: string
label: string
prompt: string
- icon: Icon
+ icon: ActionIcon
modules: readonly ModuleTag[]
featured: boolean
popular: boolean
@@ -101,7 +104,7 @@ const CANDIDATES: readonly Candidate[] = (() => {
blockType,
label: template.title,
prompt: template.prompt,
- icon: template.icon as Icon,
+ icon: template.icon as ActionIcon,
modules: template.modules,
featured: template.featured ?? false,
popular: template.category === 'popular',
@@ -147,34 +150,13 @@ function scoreCandidate(c: Candidate, signals: Signals): number {
return weight
}
-/**
- * Weighted sampling without replacement. Each pick's probability is
- * proportional to its weight, so the set stays varied while staying relevant.
- */
-function weightedSample(pool: readonly T[], n: number, weightOf: (item: T) => number): T[] {
- const remaining = pool.map((item) => ({ item, weight: Math.max(weightOf(item), 0) }))
- const out: T[] = []
- while (out.length < n && remaining.length > 0) {
- const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
- if (total <= 0) break
- let roll = randomFloat() * total
- const index = remaining.findIndex((entry) => {
- roll -= entry.weight
- return roll <= 0
- })
- const [picked] = remaining.splice(index === -1 ? remaining.length - 1 : index, 1)
- out.push(picked.item)
- }
- return out
-}
-
const EMPTY_CREDENTIALS: NonNullable['data']> = []
const EMPTY_SERVICES: NonNullable['data']> = []
type ServiceInfo = NonNullable['data']>[number]
function toPromptAction(c: Candidate): Action {
- return { kind: 'prompt', id: c.id, label: c.label, prompt: c.prompt, icon: c.icon }
+ return { kind: 'prompt', id: c.id, label: c.label, icon: c.icon, prompt: c.prompt }
}
function toIntegrationAction(service: ServiceInfo, slug: string): Action {
@@ -251,6 +233,12 @@ const INITIAL_ACTIONS: Action[] = [
.map(toPromptAction),
]
+/** Section heading per composer mode — Search reads as a connect-your-sources list. */
+const HEADINGS: Record = {
+ build: 'Suggested actions',
+ search: 'Connect Sim Search',
+}
+
interface SuggestedActionsProps {
onSelectPrompt: (prompt: string) => void
}
@@ -258,6 +246,8 @@ interface SuggestedActionsProps {
export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const posthog = usePostHog()
+ const mode = useMothershipModeStore((state) => state.mode)
+ const { integrationAvailability } = usePermissionConfig()
const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({
workspaceId,
@@ -268,6 +258,8 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
enabled: Boolean(workspaceId),
})
+ const { credentials: searchCredentials, isPending: searchCredentialsPending } =
+ useSearchCredentials(workspaceId)
const [expanded, setExpanded] = useState(true)
/**
@@ -282,7 +274,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
* to `null` (via `onOpenChange(false)`) closes it. Mirrors the local-state
* pattern used by the integrations detail page.
*/
- const [oauthTarget, setOAuthTarget] = useState(null)
+ const [oauthTarget, setOAuthTarget] = useState(null)
const connectedProviders = useMemo(
() =>
@@ -304,17 +296,44 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
[connectedProviders, tables.length, knowledgeBases.length]
)
+ const connectedSearchProviders = useMemo(
+ () =>
+ new Set(
+ searchCredentials
+ .map((credential) => credential.providerId)
+ .filter((providerId): providerId is string => Boolean(providerId))
+ ),
+ [searchCredentials]
+ )
+
/**
- * Personalized suggestions, re-sampled whenever signals resolve. Falls back to
+ * Each mode's list is memoized on its own inputs alone, so switching modes —
+ * or the other mode's signals settling — never re-samples it.
+ *
+ * Search lists connectors to attach, and waits for the viewer's credentials:
+ * sampling against an empty set would list connected providers and then
+ * reshuffle when the query lands. Build lists personalized suggestions,
+ * re-sampled whenever signals resolve, and falls back to
* {@link INITIAL_ACTIONS} until the credential and service queries have loaded
* — and stays there for users with no connections — so first paint never
- * flashes.
+ * flashes. The store's default mode is Build, so the server render never
+ * shows the sampled Search list.
*/
- const actions = useMemo(() => {
+ const searchActions = useMemo(
+ () =>
+ searchCredentialsPending
+ ? []
+ : computeConnectorActions(connectedSearchProviders, (connector) =>
+ isSearchConnectorAvailable(connector, integrationAvailability)
+ ),
+ [searchCredentialsPending, connectedSearchProviders, integrationAvailability]
+ )
+ const buildActions = useMemo(() => {
const personalized = services.length > 0 && connectedProviders.size > 0
if (!personalized) return INITIAL_ACTIONS
return computeActions(services, signals)
}, [connectedProviders, services, signals])
+ const actions = mode === 'search' ? searchActions : buildActions
const handleSelect = (action: Action, position: number) => {
captureEvent(posthog, 'suggested_action_clicked', {
@@ -323,14 +342,16 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
action_id: action.id,
label: action.label,
position,
- connected_provider_count: connectedProviders.size,
+ connected_provider_count:
+ action.kind === 'connector' ? connectedSearchProviders.size : connectedProviders.size,
})
if (action.kind === 'prompt') {
onSelectPrompt(action.prompt)
return
}
- const match = resolveOAuthServiceForSlug(action.slug)
- if (match) setOAuthTarget(match)
+ const target =
+ action.kind === 'connector' ? action.target : resolveOAuthServiceForSlug(action.slug)
+ if (target) setOAuthTarget(target)
}
const handleToggleExpanded = () => {
@@ -351,7 +372,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
aria-expanded={expanded}
className='group/toggle flex w-full cursor-pointer items-center gap-2'
>
- Suggested actions
+ {HEADINGS[mode]}
{/*
* Revealed by hovering anywhere in the section — the group sits on the
* section wrapper rather than this row, so the action rows below arm it just
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/types.ts b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/types.ts
new file mode 100644
index 00000000000..98c51898c9c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/types.ts
@@ -0,0 +1,22 @@
+import type { ComponentType, CSSProperties } from 'react'
+
+export type ActionIcon = ComponentType<{ className?: string; style?: CSSProperties }>
+
+/** What the OAuth connect modal needs to start a connection for one service. */
+export interface OAuthConnectTarget {
+ providerId: string
+ requiredScopes: readonly string[]
+ serviceName: string
+ serviceIcon: ComponentType<{ className?: string }>
+}
+
+/**
+ * One suggested-action row. `prompt` rows populate the input with a curated
+ * prompt; `integration` rows resolve their OAuth service from the catalog slug
+ * on click; `connector` rows — the Search-mode "Connect X" rows — carry their
+ * connect target directly. Both connecting kinds open the OAuth connect modal.
+ */
+export type Action =
+ | { kind: 'prompt'; id: string; label: string; icon: ActionIcon; prompt: string }
+ | { kind: 'integration'; id: string; label: string; icon: ActionIcon; slug: string }
+ | { kind: 'connector'; id: string; label: string; icon: ActionIcon; target: OAuthConnectTarget }
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample.ts b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample.ts
new file mode 100644
index 00000000000..b8b6c66607a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/weighted-sample.ts
@@ -0,0 +1,27 @@
+import { randomFloat } from '@sim/utils/random'
+
+/**
+ * Weighted sampling without replacement. Each pick's probability is
+ * proportional to its weight, so the set stays varied while staying relevant.
+ * A constant weight yields a uniform sample.
+ */
+export function weightedSample(
+ pool: readonly T[],
+ n: number,
+ weightOf: (item: T) => number
+): T[] {
+ const remaining = pool.map((item) => ({ item, weight: Math.max(weightOf(item), 0) }))
+ const out: T[] = []
+ while (out.length < n && remaining.length > 0) {
+ const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
+ if (total <= 0) break
+ let roll = randomFloat() * total
+ const index = remaining.findIndex((entry) => {
+ roll -= entry.weight
+ return roll <= 0
+ })
+ const [picked] = remaining.splice(index === -1 ? remaining.length - 1 : index, 1)
+ out.push(picked.item)
+ }
+ return out
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
index 95d472588c1..7d8bdca03af 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
@@ -24,6 +24,7 @@ export {
export { DropOverlay } from './drop-overlay'
export { MicButton } from './mic-button'
export { MicrophonePermissionHelp } from './microphone-permission-help'
+export { ModeSwitcher } from './mode-switcher'
export { PlusMenuDropdown } from './plus-menu-dropdown'
export type {
PromptEditorInstance,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts
new file mode 100644
index 00000000000..46800468812
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/index.ts
@@ -0,0 +1 @@
+export { ModeSwitcher } from './mode-switcher'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx
new file mode 100644
index 00000000000..f44dbb101a5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockCaptureEvent } = vi.hoisted(() => ({ mockCaptureEvent: vi.fn() }))
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ workspaceId: 'workspace-1' }),
+}))
+vi.mock('posthog-js/react', () => ({ usePostHog: () => null }))
+vi.mock('@/lib/posthog/client', () => ({ captureEvent: mockCaptureEvent }))
+
+import { ModeSwitcher } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher'
+import { useMothershipModeStore } from '@/stores/mothership-mode/store'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+
+function mount() {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root?.render())
+}
+
+function trigger(): HTMLButtonElement {
+ const node = container?.querySelector('button')
+ if (!node) throw new Error('Switcher trigger did not render')
+ return node
+}
+
+/** Opens the menu the way a pointer does — Radix opens on `pointerdown`. */
+function openMenu() {
+ act(() => {
+ trigger().dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
+ })
+}
+
+function items(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="menuitem"]'))
+}
+
+beforeEach(() => {
+ mockCaptureEvent.mockClear()
+ useMothershipModeStore.getState().reset()
+})
+
+afterEach(() => {
+ if (root) act(() => root?.unmount())
+ container?.remove()
+ root = null
+ container = null
+})
+
+describe('ModeSwitcher', () => {
+ it('renders the active mode as a label-only round chip and defaults to Build', () => {
+ mount()
+
+ const button = trigger()
+ expect(button.textContent).toBe('Build')
+ expect(button.getAttribute('aria-label')).toBe('Mode: Build')
+ expect(button.className).toContain('h-[30px]')
+ expect(button.className).toContain('rounded-full')
+ expect(button.className).not.toContain('rounded-lg')
+ expect(button.className).toContain('hover-hover:bg-[var(--surface-hover)]')
+ expect(button.querySelector('svg')).toBeNull()
+ })
+
+ it('lists both modes and checks the active one', () => {
+ mount()
+ openMenu()
+
+ const rows = items()
+ expect(rows.map((row) => row.textContent)).toEqual(['Build', 'Search'])
+ expect(rows[0].querySelector('svg')).not.toBeNull()
+ expect(rows[1].querySelector('svg')).toBeNull()
+ })
+
+ it('switches the shared mode and reports the change', () => {
+ mount()
+ openMenu()
+
+ act(() => {
+ items()[1].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
+ })
+
+ expect(useMothershipModeStore.getState().mode).toBe('search')
+ expect(trigger().textContent).toBe('Search')
+ expect(mockCaptureEvent).toHaveBeenCalledWith(null, 'chat_mode_changed', {
+ workspace_id: 'workspace-1',
+ mode: 'search',
+ })
+ })
+
+ it('does not report re-selecting the active mode', () => {
+ mount()
+ openMenu()
+
+ act(() => {
+ items()[0].dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }))
+ })
+
+ expect(useMothershipModeStore.getState().mode).toBe('build')
+ expect(mockCaptureEvent).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx
new file mode 100644
index 00000000000..60d58ada236
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mode-switcher/mode-switcher.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { memo } from 'react'
+import {
+ Chip,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuItemLabel,
+ DropdownMenuTrigger,
+} from '@sim/emcn'
+import { Check } from '@sim/emcn/icons'
+import { useParams } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import { captureEvent } from '@/lib/posthog/client'
+import {
+ MOTHERSHIP_MODES,
+ type MothershipMode,
+ useMothershipModeStore,
+} from '@/stores/mothership-mode/store'
+
+const MODE_LABELS: Record = {
+ build: 'Build',
+ search: 'Search',
+}
+
+/**
+ * The composer's Build / Search switcher: a label-only `Chip` in its `round`
+ * shape — chip chrome throughout (`--text-body` label, `--surface-hover` on
+ * hover, no text-color shift), fully round to sit in the toolbar's row of
+ * round controls — opening a two-row menu that checks the active mode, as
+ * `ChipDropdown` does.
+ */
+export const ModeSwitcher = memo(function ModeSwitcher() {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const posthog = usePostHog()
+ const mode = useMothershipModeStore((state) => state.mode)
+ const setMode = useMothershipModeStore((state) => state.setMode)
+
+ const handleSelect = (next: MothershipMode) => {
+ if (next === mode) return
+ setMode(next)
+ captureEvent(posthog, 'chat_mode_changed', { workspace_id: workspaceId, mode: next })
+ }
+
+ return (
+
+
+
+ {MODE_LABELS[mode]}
+
+
+
+ {MOTHERSHIP_MODES.map((option) => (
+ handleSelect(option)}>
+
+ {option === mode && }
+
+ ))}
+
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 13c23d419cf..3b0bb976a7b 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -25,6 +25,7 @@ import {
DropOverlay,
MicButton,
MicrophonePermissionHelp,
+ ModeSwitcher,
PromptEditor,
SendButton,
usePromptEditor,
@@ -512,8 +513,12 @@ const UserInputImpl = forwardRef(function UserI
return () => window.cancelAnimationFrame(raf)
}, [textareaRef])
+ /**
+ * Menu rows are excluded alongside buttons: the mode switcher's items are
+ * portaled, so their clicks still bubble here through the React tree.
+ */
const handleContainerClick = (e: React.MouseEvent) => {
- if ((e.target as HTMLElement).closest('button, [role="dialog"]')) return
+ if ((e.target as HTMLElement).closest('button, [role="dialog"], [role="menu"]')) return
textareaRef.current?.focus()
}
@@ -678,6 +683,7 @@ const UserInputImpl = forwardRef(function UserI
+
{isSttSupported && (
+}) {
+ const { workspaceId, credentialId } = await params
+ return
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/search/connected/[credentialId]/search-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/search/connected/[credentialId]/search-credential-detail.tsx
new file mode 100644
index 00000000000..8f00ee19c60
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/search/connected/[credentialId]/search-credential-detail.tsx
@@ -0,0 +1,240 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ Chip,
+ ChipConfirmModal,
+ ChipCopyInput,
+ ChipInput,
+ ChipLink,
+ ChipTextarea,
+ cn,
+ toast,
+} from '@sim/emcn'
+import { ArrowLeft } from '@sim/emcn/icons'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useRouter } from 'next/navigation'
+import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
+import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
+import { resolveCredentialDisplay } from '@/lib/integrations'
+import {
+ CredentialDetailHeading,
+ CredentialDetailLayout,
+ DetailSection,
+ UnsavedChangesModal,
+ useCredentialDetailForm,
+} from '@/app/workspace/[workspaceId]/components/credential-detail'
+import {
+ RESOURCE_TILE_BASE,
+ RESOURCE_TILE_PLAIN,
+} from '@/app/workspace/[workspaceId]/components/resource-tile'
+import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
+import { useSearchCredentials } from '@/app/workspace/[workspaceId]/search/hooks/use-search-credentials'
+import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { useCreateCredentialDraft, useDeleteWorkspaceCredential } from '@/hooks/queries/credentials'
+import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
+import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
+
+const logger = createLogger('SearchCredentialDetail')
+
+interface SearchCredentialDetailProps {
+ workspaceId: string
+ credentialId: string
+}
+
+/**
+ * A connected Sim Search credential: the integrations connected-credential page
+ * without its sharing. Connections here are personal, so there is no Share
+ * action and no members section — the credential resolves only when the viewer
+ * created it, and anyone else lands on "not found". Reconnect and Disconnect
+ * go through the same credential draft and delete paths as integrations.
+ */
+export function SearchCredentialDetail({ workspaceId, credentialId }: SearchCredentialDetailProps) {
+ const router = useRouter()
+ const searchHref = `/workspace/${workspaceId}/search`
+
+ useOAuthReturnRouter()
+
+ const { credentials, isPending: credentialsLoading } = useSearchCredentials(workspaceId)
+ const connectOAuthService = useConnectOAuthService()
+ const createDraft = useCreateCredentialDraft()
+ const deleteCredential = useDeleteWorkspaceCredential()
+
+ const credential = credentials.find((c) => c.id === credentialId) ?? null
+ const isAdmin = credential?.role === 'admin'
+
+ const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
+
+ const form = useCredentialDetailForm({ credential, isAdmin, backHref: searchHref })
+
+ const display = credential ? resolveCredentialDisplay(credential) : null
+
+ const handleReconnect = async () => {
+ if (!credential?.providerId) return
+ try {
+ const draft = await createDraft.mutateAsync({
+ workspaceId,
+ providerId: credential.providerId,
+ displayName: credential.displayName,
+ description: credential.description || undefined,
+ credentialId: credential.id,
+ })
+ writeOAuthReturnContext({
+ origin: 'integrations',
+ displayName: credential.displayName,
+ providerId: credential.providerId,
+ preCount: credentials.filter((c) => c.providerId === credential.providerId).length,
+ workspaceId,
+ reconnect: true,
+ requestedAt: Date.now(),
+ })
+ await connectOAuthService.mutateAsync({
+ providerId: credential.providerId,
+ callbackURL: window.location.href,
+ draftId: draft.draftId,
+ })
+ } catch (error: unknown) {
+ toast.error("Couldn't start reconnect", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ logger.error('Failed to reconnect Sim Search credential', error)
+ }
+ }
+
+ const handleConfirmDelete = async () => {
+ if (!credential) return
+ try {
+ await deleteCredential.mutateAsync(credential.id)
+ setShowDeleteConfirmDialog(false)
+ router.push(searchHref)
+ } catch (error) {
+ toast.error("Couldn't disconnect", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ logger.error('Failed to disconnect Sim Search credential', error)
+ }
+ }
+
+ const back = (
+
+ Search
+
+ )
+
+ const actions =
+ credential && isAdmin ? (
+ <>
+
+ Reconnect
+
+ setShowDeleteConfirmDialog(true)}
+ disabled={deleteCredential.isPending}
+ >
+ Disconnect
+
+
+ >
+ ) : null
+
+ if (credentialsLoading && !credential) {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ if (!credential) {
+ return (
+
+ Credential not found.
+
+ )
+ }
+
+ return (
+ <>
+
+
+ ) : (
+