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 ( -
- {groups.map((group, i) => { - if (group.kind === 'inline') { - return ( -
:first-child]:mt-0 [&>:last-child]:mb-0')} - > - +
+ {groups.map((group, i) => { + if (group.kind === 'inline') { + return ( +
:first-child]:mt-0 [&>:last-child]:mb-0')} > - {group.markdown} - -
+ + {group.markdown} + +
+ ) + } + return ( + ) - } - 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 ( + <> + + + ) : ( +
+ + {credential.displayName.slice(0, 1) || '?'} + +
+ ) + } + title={display?.detailTitle ?? credential.displayName} + subtitle={display?.detailSubtitle ?? 'Connected service'} + /> + + + + + + + form.setDisplayNameDraft(event.target.value)} + autoComplete='off' + data-lpignore='true' + disabled={!isAdmin} + /> + + + + form.setDescriptionDraft(event.target.value)} + placeholder='Add a description...' + maxLength={500} + autoComplete='off' + data-lpignore='true' + disabled={!isAdmin} + /> + +
+ + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/search/error.tsx b/apps/sim/app/workspace/[workspaceId]/search/error.tsx new file mode 100644 index 00000000000..d4520d2d64f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function SearchError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/search/hooks/use-search-credentials.ts b/apps/sim/app/workspace/[workspaceId]/search/hooks/use-search-credentials.ts new file mode 100644 index 00000000000..c298ca4e737 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/hooks/use-search-credentials.ts @@ -0,0 +1,48 @@ +'use client' + +import { useMemo } from 'react' +import { useSession } from '@/lib/auth/auth-client' +import { isSearchConnectorProvider } from '@/lib/sim-search/connectors' +import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' + +const EMPTY_CREDENTIALS: readonly WorkspaceCredential[] = [] + +interface UseSearchCredentialsResult { + /** The viewer's own OAuth credentials for Sim Search connector providers. */ + credentials: readonly WorkspaceCredential[] + isPending: boolean +} + +/** + * The credentials the Sim Search surface shows as connected. Sim Search + * connections are personal: the surface lists only credentials the viewer + * created, so a workspace admin — who can see every shared credential — still + * sees just their own here. Service accounts are excluded, as they are from the + * knowledge-base connector picker: no connector can authenticate with one. + * + * Reads the same unfiltered workspace credential query the integrations pages + * use, so the two surfaces share one cache and one invalidation path. + */ +export function useSearchCredentials(workspaceId: string): UseSearchCredentialsResult { + const { data: session, isPending: sessionPending } = useSession() + const userId = session?.user?.id + const { data: allCredentials, isPending: credentialsPending } = useWorkspaceCredentials({ + workspaceId, + enabled: Boolean(workspaceId), + }) + + const credentials = useMemo(() => { + if (!userId || !allCredentials) return EMPTY_CREDENTIALS + return allCredentials.filter( + (credential) => + credential.type === 'oauth' && + credential.createdBy === userId && + isSearchConnectorProvider(credential.providerId) + ) + }, [allCredentials, userId]) + + return { + credentials, + isPending: sessionPending || (Boolean(workspaceId) && credentialsPending), + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/search/page.tsx b/apps/sim/app/workspace/[workspaceId]/search/page.tsx new file mode 100644 index 00000000000..1623fd074e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components' +import { Search } from '@/app/workspace/[workspaceId]/search/search' + +export const metadata: Metadata = { + title: 'Search', +} + +/** + * Sim Search page entry. `Search` reads URL query params via nuqs (which uses + * `useSearchParams` internally), so it must sit under a Suspense boundary. The + * fallback renders the real page chrome (background + tab header) so a suspend + * never shows a blank frame. + */ +export default async function SearchPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + return ( + + +
+ } + > + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/search/search-params.ts b/apps/sim/app/workspace/[workspaceId]/search/search-params.ts new file mode 100644 index 00000000000..c55f7709913 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/search-params.ts @@ -0,0 +1,17 @@ +import { parseAsString } from 'nuqs/server' + +/** + * `search` filters the Sim Search connector list by name and description. The + * input is controlled directly by the instant nuqs value; only its URL write is + * debounced via `useDebouncedSearchSetter` — never written on every keystroke. + */ +export const connectorSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search is filter view-state: clean URLs, no back-stack churn. */ +export const connectorSearchUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx new file mode 100644 index 00000000000..ad77ad25833 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx @@ -0,0 +1,181 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) +vi.mock('nuqs', () => ({ + useQueryState: () => ['', vi.fn()], +})) +vi.mock('@/hooks/use-debounced-search-setter', () => ({ + useDebouncedSearchSetter: (write: (value: string) => void) => write, +})) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'user-1' } }, isPending: false }), +})) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + integrationAvailability: new Map([ + ['confluence', { state: 'ready', oauthAvailable: true }], + /* A service-account-only deployment: the block is usable, the OAuth path is not. */ + ['slack', { state: 'limited', oauthAvailable: false }], + ]), + }), +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration', () => ({ + useScrollRestoration: () => {}, +})) +vi.mock('@/hooks/use-oauth-return', () => ({ + useOAuthReturnRouter: () => {}, +})) +vi.mock('@/app/workspace/[workspaceId]/components', () => ({ + IntegrationTabsHeader: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({ + ConnectOAuthModal: ({ open, providerId }: { open: boolean; providerId: string }) => + open ?
{providerId}
: null, +})) +vi.mock('@/blocks', () => ({ getBlock: () => undefined })) +vi.mock('@/lib/integrations', () => ({ + resolveCredentialDisplay: () => ({ icon: () => null, blockType: 'confluence', subtitle: 'Sub' }), +})) + +vi.mock('@/lib/sim-search/connectors', () => { + const icon = () => null + const connector = (type: string, name: string, description: string) => ({ + type, + meta: { id: type, name, description, icon }, + providerId: type, + providerIds: [type], + requiredScopes: [], + serviceName: name, + serviceIcon: icon, + blockType: type, + }) + const providers = new Set(['confluence', 'jira', 'slack']) + return { + isSearchConnectorAvailable: ( + candidate: { blockType: string }, + availability: ReadonlyMap + ) => availability.get(candidate.blockType)?.oauthAvailable ?? true, + SEARCH_CONNECTORS: [ + connector('confluence', 'Confluence', 'Sync Confluence pages'), + connector('jira', 'Jira', 'Sync Jira issues'), + connector('slack', 'Slack', 'Sync Slack messages'), + ], + isSearchConnectorProvider: (providerId: string | null) => + providerId !== null && providers.has(providerId), + } +}) + +const credential = (overrides: Record) => ({ + id: 'cred', + workspaceId: 'workspace-1', + type: 'oauth', + displayName: 'Credential', + description: null, + unredacted: false, + providerId: 'confluence', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '', + updatedAt: '', + role: 'admin', + ...overrides, +}) + +vi.mock('@/hooks/queries/credentials', () => ({ + useWorkspaceCredentials: () => ({ + isPending: false, + data: [ + credential({ id: 'cred-mine', displayName: 'My Confluence' }), + credential({ + id: 'cred-theirs', + displayName: 'Teammate Jira', + providerId: 'jira', + createdBy: 'user-2', + }), + credential({ id: 'cred-sa', displayName: 'Service Account', type: 'service_account' }), + credential({ id: 'cred-github', displayName: 'My GitHub', providerId: 'github' }), + ], + }), +})) + +import { Search } from '@/app/workspace/[workspaceId]/search/search' + +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 sectionLabels(): string[] { + return Array.from(container?.querySelectorAll('section > div > span') ?? []).map( + (node) => node.textContent ?? '' + ) +} + +function hrefs(): Array { + return Array.from(container?.querySelectorAll('a') ?? []).map((a) => a.getAttribute('href')) +} + +function connectButton(name: string): HTMLButtonElement | null { + return container?.querySelector(`button[aria-label="Connect ${name}"]`) ?? null +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('Search', () => { + it('lists the viewer’s own search-connector credentials under Connected', () => { + mount() + + expect(sectionLabels()).toEqual(['Connected', 'Sim Search Connectors']) + const text = container?.textContent ?? '' + expect(text).toContain('My Confluence') + expect(text).not.toContain('Teammate Jira') + expect(text).not.toContain('Service Account') + expect(text).not.toContain('My GitHub') + expect(hrefs()).toContain('/workspace/workspace-1/search/connected/cred-mine') + }) + + it('opens the connect modal for a connector instead of navigating', () => { + mount() + + const connect = connectButton('Confluence') + expect(connect).not.toBeNull() + expect(hrefs()).not.toContain('/workspace/workspace-1/search/confluence') + expect(document.querySelector('[data-testid="connect-modal"]')).toBeNull() + + act(() => { + connect?.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) + }) + + expect(document.querySelector('[data-testid="connect-modal"]')?.textContent).toBe('confluence') + }) + + it('disables a connector whose OAuth path is unavailable, even when the block is usable', () => { + mount() + + expect(connectButton('Jira')).not.toBeNull() + expect(connectButton('Slack')).toBeNull() + expect(container?.textContent).toContain( + 'Unavailable in this deployment. Contact your administrator.' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.tsx new file mode 100644 index 00000000000..34c629ce2a2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/search.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useRef, useState } from 'react' +import { ChipInput } from '@sim/emcn' +import { Search as SearchIcon } from '@sim/emcn/icons' +import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' +import { resolveCredentialDisplay } from '@/lib/integrations' +import { + isSearchConnectorAvailable, + SEARCH_CONNECTORS, + type SearchConnector, +} from '@/lib/sim-search/connectors' +import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' +import { CONNECTED_LABEL } from '@/app/workspace/[workspaceId]/integrations/search-params' +import { useSearchCredentials } from '@/app/workspace/[workspaceId]/search/hooks/use-search-credentials' +import { + connectorSearchParam, + connectorSearchUrlKeys, +} from '@/app/workspace/[workspaceId]/search/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import type { WorkspaceCredential } from '@/hooks/queries/credentials' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +const CONNECTORS_LABEL = 'Sim Search Connectors' + +interface ConnectorItemProps { + connector: SearchConnector + unavailable: boolean + onConnect: (connector: SearchConnector) => void +} + +/** + * A connector row acts in place — it opens the connect modal — so it carries no + * navigation chevron; only a connected credential leads to a page of its own. + */ +function ConnectorItem({ connector, unavailable, onConnect }: ConnectorItemProps) { + return ( + } + title={connector.meta.name} + description={ + unavailable + ? 'Unavailable in this deployment. Contact your administrator.' + : connector.meta.description + } + onClick={unavailable ? undefined : () => onConnect(connector)} + clickLabel={`Connect ${connector.meta.name}`} + disabled={unavailable} + /> + ) +} + +interface ConnectedItemProps { + credential: WorkspaceCredential + workspaceId: string +} + +function ConnectedItem({ credential, workspaceId }: ConnectedItemProps) { + const display = resolveCredentialDisplay(credential) + if (!display.icon) return null + return ( + } + title={credential.displayName} + description={credential.description || display.subtitle} + href={`/workspace/${workspaceId}/search/connected/${credential.id}`} + clickLabel={`Open ${credential.displayName}`} + navigable + /> + ) +} + +/** + * The Sim Search connector catalog: the viewer's own connections first, then + * every connector a personal OAuth connection can power. Same shell and rows + * as the Integrations page, minus its showcase and category filter — the + * connector set is small enough that the search box alone narrows it. A + * connector row opens the OAuth connect modal right here; the OAuth redirect + * lands back on this page, where the return router reports the outcome. + */ +export function Search() { + const scrollContainerRef = useRef(null) + useOAuthReturnRouter() + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + const { integrationAvailability } = usePermissionConfig() + const [connectTarget, setConnectTarget] = useState(null) + + const [searchTerm, setSearchTermParam] = useQueryState(connectorSearchParam.key, { + ...connectorSearchParam.parser, + ...connectorSearchUrlKeys, + }) + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. Filtering below is cheap in-memory over a static list, + * so it reads the instant value too. + */ + const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) + + const { credentials, isPending: credentialsLoading } = useSearchCredentials(workspaceId) + + useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading }) + + const normalizedSearch = searchTerm.trim().toLowerCase() + + const visibleCredentials = normalizedSearch + ? credentials.filter((credential) => { + const display = resolveCredentialDisplay(credential) + return [credential.displayName, credential.description ?? '', display.subtitle].some( + (text) => text.toLowerCase().includes(normalizedSearch) + ) + }) + : credentials + + const visibleConnectors = normalizedSearch + ? SEARCH_CONNECTORS.filter( + (connector) => + connector.meta.name.toLowerCase().includes(normalizedSearch) || + connector.meta.description.toLowerCase().includes(normalizedSearch) + ) + : SEARCH_CONNECTORS + + const showNoResults = + Boolean(normalizedSearch) && visibleCredentials.length === 0 && visibleConnectors.length === 0 + + return ( +
+ +
+
+ setSearchTerm(e.target.value)} + disabled={credentialsLoading} + /> + +
+ {visibleCredentials.length > 0 && ( + + {visibleCredentials.map((credential) => ( + + ))} + + )} + + {visibleConnectors.length > 0 && ( + + {visibleConnectors.map((connector) => ( + + ))} + + )} + + {showNoResults && ( + + No connectors found matching “{searchTerm}” + + )} +
+
+
+ {connectTarget && workspaceId && ( + { + if (!open) setConnectTarget(null) + }} + workspaceId={workspaceId} + providerId={connectTarget.providerId} + requiredScopes={connectTarget.requiredScopes} + serviceName={connectTarget.serviceName} + serviceIcon={connectTarget.serviceIcon} + /> + )} +
+ ) +} diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts index 0baac25f776..9d4b794a32b 100644 --- a/apps/sim/components/emails/_styles/base.tokens.test.ts +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -91,8 +91,16 @@ describe('email geometry mirrors the platform', () => { }) it('the CTA transcribes chipGeometryClass', () => { - const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1] - expect(geometry).toBeDefined() + // chipGeometryClass composes the unrounded geometry with the default radius, + // so the transcription reads both halves rather than one literal. + expect(chipChrome).toMatch( + /chipGeometryClass = `\$\{chipGeometryUnroundedClass\} \$\{chipRadiusClass\}`/ + ) + const unrounded = chipChrome.match(/chipGeometryUnroundedClass = `([^`]+)`/)?.[1] + const radius = chipChrome.match(/chipRadiusClass = '([^']+)'/)?.[1] + expect(unrounded).toBeDefined() + expect(radius).toBeDefined() + const geometry = `${unrounded} ${radius}` for (const token of ['h-[30px]', 'rounded-lg', 'px-2', 'text-sm']) { expect(geometry).toContain(token) } diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 85da54723a7..71c9643839a 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -614,10 +614,19 @@ export interface PostHogEventMap { action_id?: string } - /** A home-page suggested action was clicked. `action_id` is the candidate id (e.g. `gmail-0`). */ + /** The chat composer's mode switcher picked a different mode. */ + chat_mode_changed: { + workspace_id: string + mode: 'build' | 'search' + } + + /** + * A home-page suggested action was clicked. `action_id` is the candidate id + * (e.g. `gmail-0`); `connector` rows are the Search-mode "Connect X" rows. + */ suggested_action_clicked: { workspace_id: string - kind: 'prompt' | 'integration' + kind: 'prompt' | 'integration' | 'connector' action_id: string label: string position: number diff --git a/apps/sim/lib/sim-search/connectors.test.ts b/apps/sim/lib/sim-search/connectors.test.ts new file mode 100644 index 00000000000..df5ffbd7ef1 --- /dev/null +++ b/apps/sim/lib/sim-search/connectors.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/connectors/registry', () => { + const icon = () => null + return { + CONNECTOR_META_REGISTRY: { + mintlify: { id: 'mintlify', name: 'Mintlify', auth: { mode: 'apiKey' }, icon }, + jsm: { + id: 'jsm', + name: 'Jira Service Management', + auth: { mode: 'oauth', provider: 'jira' }, + icon, + }, + jira: { id: 'jira', name: 'Jira', auth: { mode: 'oauth', provider: 'jira' }, icon }, + google_drive: { + id: 'google_drive', + name: 'Google Drive', + auth: { mode: 'oauth', provider: 'google-drive' }, + icon, + }, + gmail: { + id: 'gmail', + name: 'Gmail', + auth: { mode: 'oauth', provider: 'google-email' }, + icon, + }, + unknown: { + id: 'unknown', + name: 'Unknown', + auth: { mode: 'oauth', provider: 'not-a-service' }, + icon, + }, + salesforce: { + id: 'salesforce', + name: 'Salesforce', + auth: { mode: 'oauth', provider: 'salesforce' }, + icon, + }, + }, + } +}) + +vi.mock('@/lib/oauth', () => { + const services = { + jira: { providerId: 'jira', name: 'Jira', icon: () => null }, + 'google-drive': { providerId: 'google-drive', name: 'Google Drive', icon: () => null }, + gmail: { providerId: 'google-email', name: 'Gmail', icon: () => null }, + salesforce: { + providerId: 'salesforce', + name: 'Salesforce', + icon: () => null, + additionalProviderIds: ['salesforce-sandbox'], + }, + } + return { + getServiceConfigByServiceId: (serviceId: string) => + services[serviceId as keyof typeof services] ?? null, + getServiceConfigByProviderId: (providerId: string) => + Object.values(services).find((service) => service.providerId === providerId) ?? null, + getCanonicalScopesForProvider: (providerId: string) => [`${providerId}:read`], + } +}) + +vi.mock('@/lib/integrations/credential-display', () => ({ + getIntegrationsForCredentialProvider: (providerId: string) => + providerId === 'jira' ? [{ type: 'jira' }] : [], +})) + +import { + isSearchConnectorAvailable, + isSearchConnectorConnected, + isSearchConnectorProvider, + SEARCH_CONNECTORS, +} from '@/lib/sim-search/connectors' + +describe('SEARCH_CONNECTORS', () => { + it('lists OAuth connectors with a registered service, alphabetically', () => { + expect(SEARCH_CONNECTORS.map((connector) => connector.type)).toEqual([ + 'gmail', + 'google_drive', + 'jira', + 'jsm', + 'salesforce', + ]) + }) + + it('resolves the provider, scopes, and brand block type per connector', () => { + const jsm = SEARCH_CONNECTORS.find((connector) => connector.type === 'jsm') + expect(jsm).toMatchObject({ + providerId: 'jira', + providerIds: ['jira'], + requiredScopes: ['jira:read'], + serviceName: 'Jira', + blockType: 'jira', + }) + const drive = SEARCH_CONNECTORS.find((connector) => connector.type === 'google_drive') + expect(drive).toMatchObject({ blockType: 'google_drive' }) + const gmail = SEARCH_CONNECTORS.find((connector) => connector.type === 'gmail') + expect(gmail).toMatchObject({ providerId: 'google-email', serviceName: 'Gmail' }) + }) +}) + +describe('isSearchConnectorProvider', () => { + it('matches credentials by provider, including shared and additional providers', () => { + expect(isSearchConnectorProvider('jira')).toBe(true) + expect(isSearchConnectorProvider('google-drive')).toBe(true) + expect(isSearchConnectorProvider('salesforce-sandbox')).toBe(true) + expect(isSearchConnectorProvider('slack')).toBe(false) + expect(isSearchConnectorProvider(null)).toBe(false) + }) +}) + +describe('isSearchConnectorConnected', () => { + it('counts a credential under any of the service’s provider ids', () => { + const salesforce = SEARCH_CONNECTORS.find((connector) => connector.type === 'salesforce')! + expect(isSearchConnectorConnected(salesforce, new Set(['salesforce-sandbox']))).toBe(true) + expect(isSearchConnectorConnected(salesforce, new Set(['jira']))).toBe(false) + }) +}) + +describe('isSearchConnectorAvailable', () => { + it('reads the OAuth path of the connector’s block, defaulting to available', () => { + const jira = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira')! + expect(isSearchConnectorAvailable(jira, new Map([['jira', { oauthAvailable: false }]]))).toBe( + false + ) + expect(isSearchConnectorAvailable(jira, new Map([['jira', { oauthAvailable: true }]]))).toBe( + true + ) + expect(isSearchConnectorAvailable(jira, new Map())).toBe(true) + }) +}) diff --git a/apps/sim/lib/sim-search/connectors.ts b/apps/sim/lib/sim-search/connectors.ts new file mode 100644 index 00000000000..fda056d4d27 --- /dev/null +++ b/apps/sim/lib/sim-search/connectors.ts @@ -0,0 +1,108 @@ +import type { ComponentType } from 'react' +import { getIntegrationsForCredentialProvider } from '@/lib/integrations/credential-display' +import { + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + getServiceConfigByServiceId, +} from '@/lib/oauth' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import type { ConnectorMeta } from '@/connectors/types' + +/** + * A knowledge-base connector offered on the Sim Search surface: the connector's + * client-safe meta paired with the OAuth service a user connects it through. + * Only OAuth connectors qualify — an API-key connector has nowhere to keep a + * personal key outside a knowledge base, so it stays a knowledge-base flow. + */ +export interface SearchConnector { + /** `CONNECTOR_META_REGISTRY` key — the id a knowledge base reports in `connectorTypes`. */ + type: string + meta: ConnectorMeta + /** Canonical OAuth provider id the connection is stored under. */ + providerId: string + /** + * Every provider id a credential for this service may carry: the canonical + * id plus any additional authorization server (Salesforce sandbox). A + * credential under any of them counts as connected. + */ + providerIds: readonly string[] + /** + * Scopes listed in the connect modal — the provider's canonical set, which is + * what the knowledge-base connector flow requests for the same provider. + */ + requiredScopes: readonly string[] + /** The OAuth service's own name and mark, for the connect modal. */ + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** + * Block type lending the brand tile and the deployment-availability lookup: + * the first catalog integration on the provider, else the connector type. + */ + blockType: string +} + +/** + * Every Sim Search connector, alphabetical by name. Built once at module load. + * + * A connector names its service by service id (`confluence`) or, for Gmail, by + * the provider id (`google-email`); the knowledge-base connector flow accepts + * both through `getProviderIdFromServiceId`'s raw fallback, so the lookup here + * tries the service id first and the provider id second. + */ +export const SEARCH_CONNECTORS: readonly SearchConnector[] = Object.entries(CONNECTOR_META_REGISTRY) + .flatMap(([type, meta]): SearchConnector[] => { + if (meta.auth.mode !== 'oauth') return [] + const service = + getServiceConfigByServiceId(meta.auth.provider) ?? + getServiceConfigByProviderId(meta.auth.provider) + if (!service) return [] + return [ + { + type, + meta, + providerId: service.providerId, + providerIds: [service.providerId, ...(service.additionalProviderIds ?? [])], + requiredScopes: getCanonicalScopesForProvider(service.providerId), + serviceName: service.name, + serviceIcon: service.icon as ComponentType<{ className?: string }>, + blockType: getIntegrationsForCredentialProvider(service.providerId)[0]?.type ?? type, + }, + ] + }) + .sort((a, b) => a.meta.name.localeCompare(b.meta.name)) + +/** + * Provider ids some Sim Search connector connects through. Several connectors + * share one (Jira and Jira Service Management both use `jira`), so a credential + * is matched to the surface by provider rather than to a single connector. + */ +const SEARCH_PROVIDER_IDS: ReadonlySet = new Set( + SEARCH_CONNECTORS.flatMap((connector) => connector.providerIds) +) + +/** Whether a stored credential's provider powers a Sim Search connector. */ +export function isSearchConnectorProvider(providerId: string | null): boolean { + return providerId !== null && SEARCH_PROVIDER_IDS.has(providerId) +} + +/** Whether the viewer has connected this connector's service under any of its provider ids. */ +export function isSearchConnectorConnected( + connector: SearchConnector, + connectedProviderIds: ReadonlySet +): boolean { + return connector.providerIds.some((providerId) => connectedProviderIds.has(providerId)) +} + +/** + * Whether this deployment can connect the connector. The OAuth path + * specifically: an integration's `state` can read `limited` on a + * service-account-only deployment, but a connector authenticates with OAuth + * alone. A connector with no availability entry is assumed connectable. + */ +export function isSearchConnectorAvailable( + connector: SearchConnector, + integrationAvailability: ReadonlyMap +): boolean { + const availability = integrationAvailability.get(connector.blockType.toLowerCase()) + return availability ? availability.oauthAvailable : true +} diff --git a/apps/sim/stores/mothership-mode/store.ts b/apps/sim/stores/mothership-mode/store.ts new file mode 100644 index 00000000000..049c2cd16bf --- /dev/null +++ b/apps/sim/stores/mothership-mode/store.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +export const MOTHERSHIP_MODES = ['build', 'search'] as const + +export type MothershipMode = (typeof MOTHERSHIP_MODES)[number] + +interface MothershipModeState { + mode: MothershipMode + setMode: (mode: MothershipMode) => void + reset: () => void +} + +const initialState: Pick = { mode: 'build' } + +/** + * The chat composer's mode — Build (default) or Search — read by the input's + * mode switcher and by the suggested actions beneath the input. + * + * A store rather than `Home` state because `Home` remounts per chat + * (`key={chatId}`) and the new-chat → `/chat/[chatId]` handoff must carry the + * mode across. Not a URL param: that handoff rewrites the path with + * `history.replaceState`, which would drop a query key, and the mode is a + * composer preference rather than a destination (the same reasoning that keeps + * canvas mode out of the URL). Deliberately not persisted, so the server render + * and the first client render agree — a persisted `search` would hydrate over + * server-rendered Build chrome. + */ +export const useMothershipModeStore = create()( + devtools( + (set) => ({ + ...initialState, + setMode: (mode) => set({ mode }), + reset: () => set(initialState), + }), + { name: 'mothership-mode-store' } + ) +) diff --git a/apps/sim/stores/reset-all-stores.ts b/apps/sim/stores/reset-all-stores.ts index 9665d01a09f..211d8180377 100644 --- a/apps/sim/stores/reset-all-stores.ts +++ b/apps/sim/stores/reset-all-stores.ts @@ -3,6 +3,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' +import { useMothershipModeStore } from '@/stores/mothership-mode/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import { useOperationQueueStore } from '@/stores/operation-queue/store' import { @@ -55,5 +56,6 @@ export async function resetAllStores(): Promise { clearAllExecutionPointers() useMothershipDraftsStore.setState({ drafts: {} }) useMothershipQueueStore.getState().reset() + useMothershipModeStore.getState().reset() await consolePersistence.persist({ merge: false }) } diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 16ab7f0fbf9..7932ebee96e 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -181,6 +181,7 @@ const ChipDropdown = forwardRef( leftIcon: LeftIcon, className, variant = 'filled', + shape, active, fullWidth, 'aria-label': ariaLabel, @@ -325,7 +326,7 @@ const ChipDropdown = forwardRef( aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={cn( - chipVariants({ variant, active, fullWidth }), + chipVariants({ variant, shape, active, fullWidth }), hasTriggerBorder && TRIGGER_BORDER_CLASS, className )} diff --git a/packages/emcn/src/components/chip/chip-chrome.ts b/packages/emcn/src/components/chip/chip-chrome.ts index c0ed45f02e6..979076d5682 100644 --- a/packages/emcn/src/components/chip/chip-chrome.ts +++ b/packages/emcn/src/components/chip/chip-chrome.ts @@ -15,8 +15,10 @@ export const chipFilledSurfaceTokens = `border border-[var(--border-1)] ${chipFi */ export const chipPrimaryFillTokens = 'bg-[var(--text-primary)] text-[var(--text-inverse)] dark:bg-white dark:text-[var(--bg)]' +/** The default chip corner radius. `chipVariants`' `shape: 'round'` swaps it for `rounded-full`. */ +export const chipRadiusClass = 'rounded-lg' /** Filled surface shared by the chip text fields ({@link ChipInput}, {@link ChipTextarea}) — aligned with `Chip` / `ChipDropdown`. */ -export const chipFieldSurfaceClass = `rounded-lg ${chipFilledSurfaceTokens} transition-colors` +export const chipFieldSurfaceClass = `${chipRadiusClass} ${chipFilledSurfaceTokens} transition-colors` /** * The raised "border + drop shadow" ring of the `border-shadow` chip variant: a * 1px hairline ring plus a soft drop shadow, in both light and dark. Single @@ -47,6 +49,13 @@ export const chipFieldTextClass = */ export const chipContentGap = 'gap-1.5' +/** + * Chip pill geometry minus its corner radius — height, centering, gap, padding, + * text size. `chipVariants` composes this with its `shape` variant so a raw + * (non-`cn`) consumer never emits two competing radii; everything else reads + * {@link chipGeometryClass}, which adds the default radius back. + */ +export const chipGeometryUnroundedClass = `h-[30px] items-center ${chipContentGap} px-2 text-left text-sm` /** * Chip pill geometry — height, centering, gap, radius, padding, text size — with * NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this @@ -54,7 +63,7 @@ export const chipContentGap = 'gap-1.5' * current-location label or a non-navigable breadcrumb) reuse it directly to * match a chip's shape without inheriting its hover. */ -export const chipGeometryClass = `h-[30px] items-center ${chipContentGap} rounded-lg px-2 text-left text-sm` +export const chipGeometryClass = `${chipGeometryUnroundedClass} ${chipRadiusClass}` /** Chip-content icon (non-inverse): 16px, non-shrinking, `--text-icon`. Inverse chip variants override the color to `currentColor`. */ export const chipContentIconClass = 'size-[16px] flex-shrink-0 text-[var(--text-icon)]' /** Fade-free single-line fallback for rich chip content. Plain text labels should render through `OverflowText`. */ diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx index 1063e1822c2..f06411da8a6 100644 --- a/packages/emcn/src/components/chip/chip.tsx +++ b/packages/emcn/src/components/chip/chip.tsx @@ -16,9 +16,10 @@ import { chipContentIconClass, chipContentLabelClass, chipFilledFillTokens, - chipGeometryClass, + chipGeometryUnroundedClass, chipHoverSurfaceClass, chipPrimaryFillTokens, + chipRadiusClass, } from './chip-chrome' /** @@ -40,6 +41,9 @@ import { * no CSS border, no fill). * `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover. * `fullWidth` swaps `inline-flex` for block-level `flex`. + * `shape` picks the corner radius: the implicit `default` is the `rounded-lg` pill; `round` is fully round + * (`rounded-full`) for a chip sitting in a row of round controls. The radius lives in this variant rather than + * in the base string so a raw `chipVariants({ shape: 'round' })` consumer emits exactly one radius. * * The chip carries NO outer margin — spacing between chips belongs to the parent, as a `gap`. It used to ship a * default `mx-0.5` "cluster margin" with a `flush` prop to switch it off, which meant a chip's visual box was not @@ -55,7 +59,7 @@ import { * {@link chipHoverSurfaceClass}. */ const chipVariants = cva( - `group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`, + `group cursor-pointer ${chipGeometryUnroundedClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`, { variants: { variant: { @@ -68,6 +72,7 @@ const chipVariants = cva( 'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)] dark:hover-hover:bg-[var(--surface-4)]', border: `shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] ${chipHoverSurfaceClass} dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]`, }, + shape: { default: chipRadiusClass, round: 'rounded-full' }, active: { true: '', false: '' }, fullWidth: { true: 'flex w-full', false: 'inline-flex' }, }, @@ -75,7 +80,7 @@ const chipVariants = cva( { variant: ['default', 'filled'], active: false, className: chipHoverSurfaceClass }, { variant: ['default', 'filled'], active: true, className: chipActiveSurfaceClass }, ], - defaultVariants: { variant: 'default', active: false, fullWidth: false }, + defaultVariants: { variant: 'default', shape: 'default', active: false, fullWidth: false }, } ) @@ -141,6 +146,7 @@ const Chip = forwardRef(function Chip( { className, variant, + shape, active, fullWidth, leftIcon, @@ -156,7 +162,7 @@ const Chip = forwardRef(function Chip(