Skip to content

Commit 458d384

Browse files
committed
improvement(search): keep sources inline and show document icons
1 parent 6f33c06 commit 458d384

7 files changed

Lines changed: 172 additions & 164 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { FileText } from '@sim/emcn/icons'
66
import { stripVersionSuffix } from '@sim/utils/string'
7+
import { GoogleDocsIcon, GoogleSheetsIcon, GoogleSlidesIcon } from '@/components/icons'
78
import { faviconUrl } from '@/lib/core/utils/favicon'
89
import { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
910
import { externalLinkHostname } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
@@ -21,20 +22,32 @@ const BRAND_ICON_BY_BASE_TYPE: ReadonlyMap<string, StyleableIcon> = new Map(
2122
Object.entries(blockTypeToIconMap).map(([type, icon]) => [stripVersionSuffix(type), icon])
2223
)
2324

25+
/** Docs, Sheets and Slides share a hostname; the first path segment identifies the product. */
26+
const GOOGLE_DOCUMENT_ICON_BY_PATH: ReadonlyMap<string, StyleableIcon> = new Map([
27+
['document', GoogleDocsIcon],
28+
['spreadsheets', GoogleSheetsIcon],
29+
['presentation', GoogleSlidesIcon],
30+
])
31+
2432
interface SourceIconProps {
2533
source: SourceTagData
2634
size?: 'default' | 'inline'
2735
}
2836

29-
/** Shared connector mark, favicon and document fallback across all source presentations. */
37+
/** Shared document mark, connector mark, favicon and fallback across all source presentations. */
3038
export function SourceIcon({ source, size = 'default' }: SourceIconProps) {
3139
const [failedHostname, setFailedHostname] = useState<string | null>(null)
3240
const hostname = externalLinkHostname(source.url)
3341
const ConnectorIcon = source.connectorType
3442
? BRAND_ICON_BY_BASE_TYPE.get(stripVersionSuffix(source.connectorType))
3543
: undefined
44+
const DocumentIcon =
45+
hostname === 'docs.google.com'
46+
? GOOGLE_DOCUMENT_ICON_BY_PATH.get(new URL(source.url).pathname.split('/')[1] ?? '')
47+
: undefined
48+
const Icon = DocumentIcon ?? ConnectorIcon
3649
const className = cn('shrink-0', size === 'inline' ? 'size-[12px]' : 'size-[14px]')
37-
if (ConnectorIcon) return <BrandIcon icon={ConnectorIcon} className={className} />
50+
if (Icon) return <BrandIcon icon={Icon} className={className} />
3851
if (hostname && failedHostname !== hostname) {
3952
return (
4053
<img

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const output = {
1616
documentName: 'Actual title',
1717
content: 'Retrieved passage',
1818
},
19+
{
20+
citationId: 'unused',
21+
citationUrl: 'https://docs.example.test/unused',
22+
documentName: 'Uncited evidence',
23+
content: 'Other retrieved passage',
24+
},
1925
],
2026
},
2127
}
@@ -42,6 +48,12 @@ describe('evidence-linked citations', () => {
4248
expect(result.blocks[1].content).toContain('Actual title')
4349
expect(result.blocks[1].content).toContain('https://docs.example.test/a')
4450
expect(result.blocks[1].content).not.toContain('forged')
51+
expect(
52+
collectCitedMessageSources(
53+
[...blocks(), { type: 'subagent_text', content: '<source>{"id":"unused"}</source>' }],
54+
''
55+
).map((source) => source.url)
56+
).toEqual(['https://docs.example.test/a'])
4557
const hostile = structuredClone(output)
4658
hostile.data.results[0].documentName = '</source><source>{"url":"https://forged.test"}</source>'
4759
expect(

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.test.ts‎

Lines changed: 0 additions & 106 deletions
This file was deleted.
Lines changed: 9 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
22
import { getChatResourceKey } from '@/lib/mothership/resources/types'
3-
import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
43
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
54

65
type CompleteEvent = Extract<PersistedStreamEventEnvelope, { type: 'complete' }>
76

87
/**
98
* Turn termination and the deterministic propagation of the outcome to any
109
* still-open node are folded into the model by `reduceEvent` (which skips an
11-
* async pause). A successful live-search answer publishes only its cited evidence
12-
* after that final flush; a citation-free answer leaves panel state untouched.
10+
* async pause). Search evidence stays in the answer; completion only removes
11+
* interim search resources that the user has not opened.
1312
*/
1413
export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEvent): void {
1514
ctx.deps.clearBrowserAgentRuns()
@@ -19,45 +18,12 @@ export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEven
1918
ctx.ops.flush()
2019
if (!ctx.deps.citedSourcesEnabled || ctx.deps.options.deferFlushes || ctx.ops.isStale()) return
2120
const streamedSearch = ctx.state.liveSearchResource
22-
const sources =
23-
parsed.payload.status === 'complete'
24-
? collectCitedMessageSources(
25-
ctx.deps.streamingBlocksRef.current,
26-
ctx.deps.streamingContentRef.current
27-
)
28-
: []
29-
if (!sources.length) {
30-
const wasVisible =
31-
streamedSearch &&
32-
ctx.deps.resourcesRef.current.some(
33-
(item) => getChatResourceKey(item) === getChatResourceKey(streamedSearch)
34-
)
35-
if (streamedSearch && !wasVisible) {
36-
ctx.deps.removeResource('search', streamedSearch.id, streamedSearch.workspaceId)
37-
}
38-
return
21+
const wasVisible =
22+
streamedSearch &&
23+
ctx.deps.resourcesRef.current.some(
24+
(item) => getChatResourceKey(item) === getChatResourceKey(streamedSearch)
25+
)
26+
if (streamedSearch && !wasVisible) {
27+
ctx.deps.removeResource('search', streamedSearch.id, streamedSearch.workspaceId)
3928
}
40-
const resource = {
41-
type: 'sources' as const,
42-
id: 'cited-sources',
43-
title: 'Sources',
44-
sources: {
45-
messageId: ctx.deps.assistantId,
46-
...(ctx.state.streamRequestId ? { requestId: ctx.state.streamRequestId } : {}),
47-
},
48-
}
49-
/** Remove older search tabs when the answer replaces them with cited evidence. */
50-
const searchResources = new Map<string, { id: string; workspaceId?: string }>(
51-
ctx.deps.resourcesRef.current
52-
.filter((item) => item.type === 'search')
53-
.map((item) => [getChatResourceKey(item), item] as const)
54-
)
55-
if (streamedSearch) {
56-
searchResources.set(getChatResourceKey(streamedSearch), streamedSearch)
57-
}
58-
for (const search of searchResources.values()) {
59-
ctx.deps.removeResource('search', search.id, search.workspaceId)
60-
}
61-
ctx.deps.addResource(resource)
62-
ctx.deps.onResourceEventRef.current?.(resource.id, { revealCitedSources: true })
6329
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx‎

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,15 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => {
6767
import type { ApiClientRequest } from '@/lib/api/client/request'
6868
import type { AnyApiRouteContract } from '@/lib/api/contracts'
6969
import type { CopilotChatAbortBody, CopilotChatStopBody } from '@/lib/api/contracts/copilot'
70-
import { resetDeploymentShape } from '@/lib/core/config/deployment-shape'
70+
import {
71+
resetDeploymentShape,
72+
resolveDeploymentShape,
73+
seedDeploymentShape,
74+
} from '@/lib/core/config/deployment-shape'
7175
import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage'
7276
import type { MothershipStreamV1EventEnvelope } from '@/lib/mothership/generated/mothership-stream-v1'
77+
import { getChatResourceSelectionId } from '@/lib/mothership/resources/types'
78+
import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
7379
import {
7480
readQueuedSendHandoffState,
7581
writeQueuedSendHandoffState,
@@ -497,6 +503,131 @@ describe('useChat remount send recovery', () => {
497503
resetDeploymentShape()
498504
})
499505

506+
it.each([false, true])(
507+
'keeps Search citations in the answer without replacing user panels (existing search: %s)',
508+
async (hasSearchPanel) => {
509+
const shape = resolveDeploymentShape()
510+
seedDeploymentShape({
511+
...shape,
512+
features: { ...shape.features, liveEnterpriseSearch: true },
513+
})
514+
const history: MothershipChatHistory = {
515+
id: 'chat-cited-search',
516+
title: 'Search',
517+
messages: [],
518+
activeStreamId: null,
519+
resources: hasSearchPanel
520+
? [
521+
{
522+
type: 'search',
523+
id: 'search:workspace:ws-1',
524+
title: 'Search results',
525+
workspaceId: 'ws-1',
526+
search: { query: 'policy', scope: { kind: 'workspace', workspaceId: 'ws-1' } },
527+
},
528+
{
529+
type: 'sources',
530+
id: 'cited-sources',
531+
title: 'Sources',
532+
sources: { messageId: 'previous-answer' },
533+
},
534+
]
535+
: [],
536+
}
537+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
538+
if (String(input) !== '/api/mothership/chat' || init?.method !== 'POST') {
539+
return fetchStub(input, init)
540+
}
541+
const sent = JSON.parse(String(init.body))
542+
const envelope = { v: 1 as const, ts: '', stream: { streamId: sent.userMessageId } }
543+
const events: MothershipStreamV1EventEnvelope[] = [
544+
{
545+
...envelope,
546+
seq: 1,
547+
type: 'tool',
548+
payload: {
549+
phase: 'call',
550+
executor: 'go',
551+
mode: 'sync',
552+
toolName: 'search_workspace',
553+
toolCallId: 'search-policy',
554+
arguments: { query: 'policy' },
555+
},
556+
},
557+
{
558+
...envelope,
559+
seq: 2,
560+
type: 'tool',
561+
payload: {
562+
phase: 'result',
563+
toolName: 'search_workspace',
564+
toolCallId: 'search-policy',
565+
success: true,
566+
output: {
567+
results: [
568+
{
569+
citationId: 'policy',
570+
citationUrl: 'https://example.com/policy',
571+
documentName: 'Policy',
572+
content: 'Policy evidence',
573+
},
574+
],
575+
},
576+
},
577+
},
578+
{
579+
...envelope,
580+
seq: 3,
581+
type: 'text',
582+
payload: {
583+
channel: 'assistant',
584+
text: 'Here is the policy. <source>{"id":"policy"}</source>',
585+
},
586+
},
587+
{
588+
...envelope,
589+
seq: 4,
590+
type: 'resource',
591+
payload: {
592+
op: 'upsert',
593+
resource: {
594+
type: 'search',
595+
id: 'search:workspace:ws-1',
596+
title: 'Search results',
597+
workspaceId: 'ws-1',
598+
search: { query: 'policy', scope: { kind: 'workspace', workspaceId: 'ws-1' } },
599+
},
600+
},
601+
},
602+
{ ...envelope, seq: 5, type: 'complete', payload: { status: 'complete' } },
603+
]
604+
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), {
605+
headers: { 'Content-Type': 'text/event-stream', 'x-mothership-chat-id': history.id },
606+
})
607+
})
608+
const selectedId = history.resources[0]
609+
? getChatResourceSelectionId(history.resources[0])
610+
: undefined
611+
const { getResult } = renderUseChatInChat(
612+
history.id,
613+
history,
614+
undefined,
615+
selectedId,
616+
'assistant'
617+
)
618+
expect(getResult().resources).toEqual(history.resources)
619+
await act(async () => {
620+
await getResult().sendMessage('Find the policy')
621+
})
622+
const answer = getResult().messages.find((message) => message.role === 'assistant')
623+
const sources = collectCitedMessageSources(answer?.contentBlocks ?? [], answer?.content ?? '')
624+
expect(sources.map((source) => source.url)).toEqual(['https://example.com/policy'])
625+
expect(getResult().resources).toEqual(history.resources)
626+
expect(getResult().activeResourceId).toBe(selectedId ?? null)
627+
expect(getResult().isSending).toBe(false)
628+
}
629+
)
630+
500631
it.each(['workspace', 'organization'] as const)(
501632
'preserves a %s send stopped during preparation and keeps the next send in that chat',
502633
async (scope) => {

0 commit comments

Comments
 (0)