Skip to content

Commit f248698

Browse files
committed
Simplify the Platform chat seam and its per-chunk work
Carry the hook's own types through the models instead of unknown casts, parse a Search target once in the projection, and move secret saving out of the card. Drop fields nothing reads. Cache settled turns with their copy text and pairing, serialize a tool payload once, follow the stream with a ResizeObserver, and read reference inventories only when a picker opens. Search stays idle behind Assistant. The read-path grammar is one shared module.
1 parent eff3a7c commit f248698

30 files changed

Lines changed: 366 additions & 314 deletions
Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseReadTarget } from '@/lib/mothership/tools/client/read-target'
12
import { getBlock, getLatestBlock } from '@/blocks/registry'
23
import type { BlockConfig } from '@/blocks/types'
34

@@ -9,19 +10,7 @@ import type { BlockConfig } from '@/blocks/types'
910
* untouched.
1011
*/
1112
export function getReadTargetBlock(path: string | undefined): BlockConfig | undefined {
12-
if (!path) return undefined
13-
const segments = path.trim().split('/').filter(Boolean)
14-
15-
if (segments[0] === 'organization' && segments[1] === 'custom-blocks' && segments.length === 3) {
16-
return getBlock(segments[2].replace(/\.json$/, ''))
17-
}
18-
19-
if (segments[0] !== 'components' || segments.length < 3) return undefined
20-
if (segments[1] === 'blocks' && segments.length === 3) {
21-
return getBlock(segments[2].replace(/\.json$/, ''))
22-
}
23-
if (segments[1] === 'integrations') {
24-
return getLatestBlock(segments[2])
25-
}
26-
return undefined
13+
const target = parseReadTarget(path)
14+
if (!target) return undefined
15+
return target.kind === 'block' ? getBlock(target.type) : getLatestBlock(target.type)
2716
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* The block a copilot `read` path names, without resolving it: a component schema or an organization
3+
* custom block by exact type, an integration by its family. Null for every other path.
4+
*/
5+
export function parseReadTarget(
6+
path: string | undefined
7+
): { kind: 'block' | 'integration'; type: string } | null {
8+
if (!path) return null
9+
const segments = path.trim().split('/').filter(Boolean)
10+
11+
if (segments[0] === 'organization' && segments[1] === 'custom-blocks' && segments.length === 3) {
12+
return { kind: 'block', type: segments[2].replace(/\.json$/, '') }
13+
}
14+
15+
if (segments[0] !== 'components' || segments.length < 3) return null
16+
if (segments[1] === 'blocks' && segments.length === 3) {
17+
return { kind: 'block', type: segments[2].replace(/\.json$/, '') }
18+
}
19+
if (segments[1] === 'integrations') {
20+
return { kind: 'integration', type: segments[2] }
21+
}
22+
return null
23+
}

‎apps/sim/platform/app/(shell)/_components/chat/cards/live-credential-card.tsx‎

Lines changed: 21 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import { Button, InlineNotice } from '@/platform/design-system/components'
1111
import { Input } from '@/platform/design-system/components/field/inputs'
1212
import {
1313
CredentialWorkspaceProvider,
14-
credentialSubmissionMessage,
15-
parseSearchTarget,
1614
useCredentialWorkspace,
1715
useFolderAccess,
1816
useIntegrationsHref,
@@ -21,7 +19,7 @@ import {
2119
useSecretSaver,
2220
useUsageLink,
2321
} from '@/platform/lib/data/chat-credentials'
24-
import { toast } from '@/platform/lib/data/toast'
22+
import type { SearchConnectionTarget } from '@/platform/lib/data/chat-types'
2523

2624
function serviceName(item: PlatformCredentialRequest): string {
2725
return (item.provider ?? 'integration').replace(/[-_]/g, ' ')
@@ -61,9 +59,14 @@ function LinkRow({ item, controlId, onConnected }: RowProps) {
6159
)
6260
}
6361

64-
function SearchRow({ item, controlId, onConnected }: RowProps) {
62+
function SearchRow({
63+
item,
64+
target,
65+
controlId,
66+
onConnected,
67+
}: RowProps & { target: SearchConnectionTarget }) {
6568
const name = serviceName(item)
66-
const connection = useSearchConnectionRow(item, controlId, onConnected)
69+
const connection = useSearchConnectionRow(target, controlId, onConnected)
6770
const label = connection.connected
6871
? `Connected ${name}`
6972
: connection.pending
@@ -135,9 +138,7 @@ export function LiveCredentialCard({
135138
const [saving, setSaving] = useState(false)
136139
const [submitted, setSubmitted] = useState(false)
137140
const secretRows = items.flatMap((item, index) =>
138-
item.type === 'secret_input' && item.name
139-
? [{ item, index, name: item.name, scope: item.scope ?? 'workspace' }]
140-
: []
141+
item.type === 'secret_input' && item.name ? [{ index, name: item.name }] : []
141142
)
142143
const integrationRows = items.flatMap((item, index) =>
143144
item.type === 'link' || item.type === 'service_account' ? [{ item, index }] : []
@@ -148,19 +149,21 @@ export function LiveCredentialCard({
148149
const named = [
149150
...integrationRows.map(({ item, index }) => ({
150151
label: serviceName(item),
151-
done: submission ? saved.has(item.provider ?? '') : connected.has(index),
152+
status: (submission ? saved.has(item.provider ?? '') : connected.has(index))
153+
? 'Connected'
154+
: 'Skipped',
152155
})),
153156
...secretRows.map(({ name, index }) => ({
154157
label: name,
155-
done: submission ? saved.has(name) : savedRows.has(index),
158+
status: (submission ? saved.has(name) : savedRows.has(index)) ? 'Added' : 'Skipped',
156159
})),
157160
]
158161
return (
159162
<dl className={styles.fields}>
160-
{named.map(({ label, done }) => (
163+
{named.map(({ label, status }) => (
161164
<div key={label}>
162165
<dt data-type='T052'>{label}</dt>
163-
<dd data-type='T048'>{done ? 'Added' : 'Skipped'}</dd>
166+
<dd data-type='T048'>{status}</dd>
164167
</div>
165168
))}
166169
</dl>
@@ -171,34 +174,12 @@ export function LiveCredentialCard({
171174

172175
async function submit() {
173176
setSaving(true)
174-
const groups = new Map<string, Record<string, string>>()
175-
for (const { index, name, scope } of secretRows) {
176-
const value = drafts[index]?.trim()
177-
if (!value || savedRows.has(index)) continue
178-
groups.set(scope, { ...groups.get(scope), [name]: value })
179-
}
180-
const next = new Set(savedRows)
181-
const results = await Promise.allSettled(
182-
[...groups].map(async ([scope, variables]) => {
183-
await saver.save(scope, workspaceId, variables)
184-
for (const { index, name, scope: rowScope } of secretRows)
185-
if (rowScope === scope && Object.hasOwn(variables, name)) next.add(index)
186-
})
187-
)
188-
setSavedRows(next)
177+
const result = await saver.submit({ drafts, saved: savedRows, connected, workspaceId })
178+
setSavedRows(result.saved)
189179
setSaving(false)
190-
if (results.some((result) => result.status === 'rejected')) {
191-
toast.error(`Couldn't save all secrets. Retry the remaining entries.`)
192-
return
193-
}
194-
const secretIndexes = new Set(
195-
secretRows.flatMap(({ index }, position) => (next.has(index) ? [position] : []))
196-
)
197-
const integrationIndexes = new Set(
198-
integrationRows.flatMap(({ index }, position) => (connected.has(index) ? [position] : []))
199-
)
180+
if (result.message === null) return
200181
setSubmitted(true)
201-
onSubmit(credentialSubmissionMessage(items, integrationIndexes, secretIndexes))
182+
onSubmit(result.message)
202183
}
203184

204185
const isCard = items.every((item) => CARD_TYPES.has(item.type))
@@ -211,10 +192,11 @@ export function LiveCredentialCard({
211192
switch (item.type) {
212193
case 'link':
213194
if (!item.provider) return null
214-
return parseSearchTarget(item) ? (
195+
return item.searchTarget ? (
215196
<SearchRow
216197
key={controlId}
217198
item={item}
199+
target={item.searchTarget}
218200
controlId={controlId}
219201
onConnected={markConnected}
220202
/>

‎apps/sim/platform/app/(shell)/_components/chat/conversation/conversation.tsx‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ import {
1919
} from '@/platform/design-system/components'
2020
import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges'
2121

22-
function Turn({ turn, onRetry }: { turn: PlatformTurn; onRetry: (id: string) => void }) {
22+
function Turn({ turn }: { turn: PlatformTurn }) {
2323
if (turn.role === 'user') return <UserMessage turn={turn} />
2424
if (turn.role === 'automated')
2525
return (
2626
<p className={styles.automated} data-type='T052'>
2727
<strong>{turn.label}</strong> {turn.summary}
2828
</p>
2929
)
30-
return <ResponseMessage turn={turn} onRetry={() => onRetry(turn.id)} />
30+
return <ResponseMessage turn={turn} />
3131
}
3232

3333
/** One exchange per article: a person's message and what answered it. */
@@ -59,17 +59,23 @@ export function Conversation({ runtime, composer, placeholders, header }: Conver
5959
const { title, turns, status, chatId, queue } = runtime
6060
const running = status === 'sending' || status === 'reconnecting'
6161
const last = turns.at(-1)
62-
const following = useRef(true)
63-
// Pinned on every commit, since a stream grows the last turn without changing its id.
62+
const thread = useRef<HTMLDivElement>(null)
63+
// A new turn always comes into view, and scrolling back up is what stops it following.
6464
useLayoutEffect(() => {
65-
const el = viewport.current
66-
if (el && following.current) el.scrollTop = el.scrollHeight
67-
})
68-
useLayoutEffect(() => {
69-
following.current = true
65+
setEarlier(false)
7066
const el = viewport.current
7167
if (el) el.scrollTop = el.scrollHeight
7268
}, [chatId, last?.id])
69+
// A stream grows the last turn without changing its id, so follow the thread's size.
70+
useLayoutEffect(() => {
71+
const el = viewport.current
72+
if (earlier || !el || !thread.current) return
73+
const observer = new ResizeObserver(() => {
74+
el.scrollTop = el.scrollHeight
75+
})
76+
observer.observe(thread.current)
77+
return () => observer.disconnect()
78+
}, [earlier])
7379
return (
7480
<ChatRuntimeProvider runtime={runtime}>
7581
<div className={styles.chat}>
@@ -88,12 +94,10 @@ export function Conversation({ runtime, composer, placeholders, header }: Conver
8894
ref={reading}
8995
onScroll={(event) => {
9096
const el = event.currentTarget
91-
const distance = el.scrollHeight - el.clientHeight - el.scrollTop
92-
following.current = distance <= FOLLOW_SLACK
93-
setEarlier(distance > FOLLOW_SLACK)
97+
setEarlier(el.scrollHeight - el.clientHeight - el.scrollTop > FOLLOW_SLACK)
9498
}}
9599
>
96-
<div className={styles.thread}>
100+
<div className={styles.thread} ref={thread}>
97101
{status === 'loading' && (
98102
<p className={styles.turn} data-type='T048' role='status'>
99103
Loading conversation
@@ -113,7 +117,7 @@ export function Conversation({ runtime, composer, placeholders, header }: Conver
113117
{groupExchanges(turns).map((exchange) => (
114118
<article key={exchange[0].id} className={styles.turn}>
115119
{exchange.map((turn) => (
116-
<Turn key={turn.id} turn={turn} onRetry={runtime.retry} />
120+
<Turn key={turn.id} turn={turn} />
117121
))}
118122
</article>
119123
))}
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
'use client'
22

33
import { Conversation } from '@/platform/app/(shell)/_components/chat/conversation/conversation'
4-
import type { ConversationData } from '@/platform/app/(shell)/_components/chat/conversation/model'
4+
import {
5+
CHAT_PLACEHOLDERS,
6+
type ConversationData,
7+
} from '@/platform/app/(shell)/_components/chat/conversation/model'
58
import { useFixtureChatRuntime } from '@/platform/app/(shell)/_components/session/chat-changes'
69
import { useComposerHost } from '@/platform/app/(shell)/_components/session/composer-host'
710

8-
const PLACEHOLDERS = {
9-
first: 'Ask about your business, or give Sim something to do…',
10-
followUp: 'Ask a follow-up…',
11-
running: 'Add a follow-up…',
12-
}
13-
1411
interface FixtureChatProps {
1512
data: ConversationData | null
1613
chatId?: string
@@ -25,5 +22,7 @@ export function FixtureChat({ data, chatId }: FixtureChatProps) {
2522
references: data?.references,
2623
initialAttachments: !id || thread ? data?.draftAttachments : undefined,
2724
})
28-
return <Conversation key={id} runtime={runtime} composer={composer} placeholders={PLACEHOLDERS} />
25+
return (
26+
<Conversation key={id} runtime={runtime} composer={composer} placeholders={CHAT_PLACEHOLDERS} />
27+
)
2928
}

‎apps/sim/platform/app/(shell)/_components/chat/conversation/model.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ export interface ConversationData {
99
references: ComposerReferences
1010
draftAttachments: ChatAttachment[]
1111
}
12+
13+
export const CHAT_PLACEHOLDERS = {
14+
first: 'Ask about your business, or give Sim something to do…',
15+
followUp: 'Ask a follow-up…',
16+
running: 'Add a follow-up…',
17+
}

‎apps/sim/platform/app/(shell)/_components/chat/live/live-chat.tsx‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4-
import { useMemo, useRef, useState } from 'react'
4+
import { useRef, useState } from 'react'
55
import {
66
LiveCredentialCard,
77
LiveUsageNotice,
88
} from '@/platform/app/(shell)/_components/chat/cards/live-credential-card'
99
import { Conversation } from '@/platform/app/(shell)/_components/chat/conversation/conversation'
10+
import { CHAT_PLACEHOLDERS } from '@/platform/app/(shell)/_components/chat/conversation/model'
1011
import {
1112
type ChatCards,
1213
ChatCardsProvider,
@@ -23,16 +24,8 @@ import { useOrganization } from '@/platform/lib/data/organization'
2324
const CARDS: ChatCards = { Credential: LiveCredentialCard, Usage: LiveUsageNotice }
2425

2526
const PLACEHOLDERS = {
26-
agent: {
27-
first: 'Ask about your business, or give Sim something to do…',
28-
followUp: 'Ask a follow-up…',
29-
running: 'Add a follow-up…',
30-
},
31-
assistant: {
32-
first: 'Ask about your company’s knowledge…',
33-
followUp: 'Ask a follow-up…',
34-
running: 'Add a follow-up…',
35-
},
27+
agent: CHAT_PLACEHOLDERS,
28+
assistant: { ...CHAT_PLACEHOLDERS, first: 'Ask about your company’s knowledge…' },
3629
}
3730

3831
interface LiveChatProps {
@@ -41,8 +34,6 @@ interface LiveChatProps {
4134
/** A surface's own controls in place of the chat title. */
4235
header?: ReactNode
4336
chatPath?: (chatId: string) => string
44-
/** Tells the surface a chat now exists, so it can drop its landing state. */
45-
children?: ReactNode
4637
}
4738

4839
function LiveConversation({ chatId, destination, header, chatPath }: LiveChatProps) {
@@ -64,15 +55,14 @@ function LiveConversation({ chatId, destination, header, chatPath }: LiveChatPro
6455
})
6556
restore.current = composer.restore
6657
const resources = usePanelResources({ chat, panel, controller })
67-
const placeholders = useMemo(() => PLACEHOLDERS[destination], [destination])
6858
return (
6959
<ChatCardsProvider value={CARDS}>
7060
<SearchTabProvider value={LiveSearchTab}>
7161
<ResourcePanels panel={resources}>
7262
<Conversation
7363
runtime={runtime}
7464
composer={composer}
75-
placeholders={placeholders}
65+
placeholders={PLACEHOLDERS[destination]}
7666
header={header}
7767
/>
7868
</ResourcePanels>

‎apps/sim/platform/app/(shell)/_components/chat/message/markdown-content.tsx‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,7 @@ import { memo, type ReactNode } from 'react'
44
import { Streamdown } from 'streamdown'
55
import styles from '@/platform/app/(shell)/_components/chat/message/markdown.module.css'
66
import { CopyButton } from '@/platform/design-system/components'
7-
8-
function textOf(node: ReactNode): string {
9-
if (typeof node === 'string' || typeof node === 'number') return String(node)
10-
if (Array.isArray(node)) return node.map(textOf).join('')
11-
if (node && typeof node === 'object' && 'props' in node)
12-
return textOf((node.props as { children?: ReactNode }).children)
13-
return ''
14-
}
7+
import { extractTextContent } from '@/platform/lib/data/node-text'
158

169
const COMPONENTS = {
1710
p: ({ children }: { children?: ReactNode }) => <p data-type='T048'>{children}</p>,
@@ -28,7 +21,7 @@ const COMPONENTS = {
2821
pre: ({ children }: { children?: ReactNode }) => (
2922
<div className={styles.code}>
3023
<span className={styles.copy}>
31-
<CopyButton label='Copy code' text={textOf(children)} />
24+
<CopyButton label='Copy code' text={extractTextContent(children)} />
3225
</span>
3326
<pre>{children}</pre>
3427
</div>

‎apps/sim/platform/app/(shell)/_components/chat/message/response-actions.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@ type Rating = 'Good response' | 'Bad response'
2020

2121
interface ResponseActionsProps {
2222
turn: Extract<PlatformTurn, { role: 'assistant' }>
23-
text: string
24-
sentAt?: number
2523
}
2624

27-
export function ResponseActions({ turn, text, sentAt }: ResponseActionsProps) {
25+
export function ResponseActions({ turn }: ResponseActionsProps) {
26+
const { copyText: text, sentAt } = turn
2827
const { rate, fork } = useChatRuntime()
2928
const [feedback, setFeedback] = useState<Rating | null>(null)
3029
const [rating, setRating] = useState<Rating | null>(null)

0 commit comments

Comments
 (0)