Skip to content

Commit 2314ceb

Browse files
committed
sending improvements
1 parent 1e3aa97 commit 2314ceb

49 files changed

Lines changed: 2704 additions & 2500 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/workspace/[workspaceId]/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ export type {
2626
RowDragDropConfig,
2727
SelectableConfig,
2828
} from './resource/resource'
29-
export { EMPTY_CELL_PLACEHOLDER, Resource, ResourceTable } from './resource/resource'
29+
export { Resource, ResourceTable } from './resource/resource'

apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getDocumentIcon } from '@/components/icons/document-icons'
1010
import { cn } from '@/lib/core/utils/cn'
1111
import { workflowBorderColor } from '@/lib/workspaces/colors'
1212
import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
13+
import { registry as blockRegistry } from '@/blocks/registry'
1314

1415
interface RenderIconArgs {
1516
context: ChatMessageContext
@@ -38,6 +39,21 @@ function renderWorkflowSquare({ className, workflowColor }: RenderIconArgs): Rea
3839
)
3940
}
4041

42+
/**
43+
* Renders the integration chip glyph: just the block's brand SVG icon, no
44+
* background tile — sized and positioned by the caller-supplied className
45+
* (same slot the `@` character normally occupies). The block is resolved
46+
* by `context.blockType` so the chip stays in sync with the registry.
47+
*/
48+
function renderIntegrationTile({ context, className }: RenderIconArgs): ReactNode | null {
49+
if (context.kind !== 'integration') return null
50+
if (!context.blockType) return null
51+
const block = blockRegistry[context.blockType]
52+
if (!block) return null
53+
const Icon = block.icon
54+
return <Icon className={className} />
55+
}
56+
4157
/**
4258
* Single source of truth for the icon and label associated with each
4359
* {@link ChatContextKind}. The `Record<ChatContextKind, …>` typing forces a
@@ -84,4 +100,5 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
84100
templates: { label: 'Templates', renderIcon: () => null },
85101
docs: { label: 'Docs', renderIcon: () => null },
86102
slash_command: { label: 'Command', renderIcon: () => null },
103+
integration: { label: 'Integration', renderIcon: renderIntegrationTile },
87104
}

apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@ import {
1616
SlackIcon,
1717
} from '@/components/icons'
1818
import { cn } from '@/lib/core/utils/cn'
19+
import { INTEGRATIONS } from '@/lib/integrations'
20+
import { integrationConnectHref } from '@/app/workspace/[workspaceId]/integrations/connect-route'
1921
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
2022
import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
2123

2224
type Icon = ComponentType<{ className?: string }>
2325

2426
type Action =
2527
| { kind: 'prompt'; id: string; label: string; prompt: string; icon: Icon }
26-
| { kind: 'integration'; id: string; label: string; icon: Icon }
28+
| { kind: 'integration'; id: string; label: string; icon: Icon; slug: string }
29+
30+
/** Lookup integration slug by OAuth service display name (case-insensitive). */
31+
const SLUG_BY_LOWER_NAME: ReadonlyMap<string, string> = new Map(
32+
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i.slug])
33+
)
2734

2835
interface PromptOption {
2936
id: string
@@ -174,12 +181,13 @@ function toPromptAction(option: PromptOption): Action {
174181
}
175182
}
176183

177-
function toIntegrationAction(service: ServiceInfo): Action {
184+
function toIntegrationAction(service: ServiceInfo, slug: string): Action {
178185
return {
179186
kind: 'integration',
180187
id: `integrate-${service.providerId}`,
181188
label: `Integrate with ${service.name}`,
182189
icon: service.icon,
190+
slug,
183191
}
184192
}
185193

@@ -190,8 +198,20 @@ function toIntegrationAction(service: ServiceInfo): Action {
190198
* replaces it with personalized integrations.
191199
*/
192200
const INITIAL_ACTIONS: Action[] = [
193-
{ kind: 'integration', id: 'integrate-slack', label: 'Integrate with Slack', icon: SlackIcon },
194-
{ kind: 'integration', id: 'integrate-gmail', label: 'Integrate with Gmail', icon: GmailIcon },
201+
{
202+
kind: 'integration',
203+
id: 'integrate-slack',
204+
label: 'Integrate with Slack',
205+
icon: SlackIcon,
206+
slug: 'slack',
207+
},
208+
{
209+
kind: 'integration',
210+
id: 'integrate-gmail',
211+
label: 'Integrate with Gmail',
212+
icon: GmailIcon,
213+
slug: 'gmail',
214+
},
195215
toPromptAction(TABLE_PROMPTS.find((p) => p.id === 'crm')!),
196216
toPromptAction(INTEGRATION_PROMPTS.find((p) => p.id === 'github-pr-review')!),
197217
]
@@ -227,8 +247,14 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
227247
useEffect(() => {
228248
if (services.length === 0 || connectedProviders.size === 0) return
229249

230-
const availableServices = services.filter((s) => !connectedProviders.has(s.providerId))
231-
const integrations = sample(availableServices, 2).map(toIntegrationAction)
250+
const candidates = services.flatMap((s) => {
251+
if (connectedProviders.has(s.providerId)) return []
252+
const slug = SLUG_BY_LOWER_NAME.get(s.name.toLowerCase())
253+
return slug ? [{ service: s, slug }] : []
254+
})
255+
const integrations = sample(candidates, 2).map(({ service, slug }) =>
256+
toIntegrationAction(service, slug)
257+
)
232258

233259
const integrationPool = INTEGRATION_PROMPTS.filter(
234260
(p) => !p.providerId || !connectedProviders.has(p.providerId)
@@ -247,7 +273,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
247273
onSelectPrompt(action.prompt)
248274
return
249275
}
250-
router.push(`/workspace/${workspaceId}/integrations`)
276+
router.push(integrationConnectHref(workspaceId, action.slug))
251277
}
252278

253279
return (

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type {
4343
import {
4444
useContextManagement,
4545
useFileAttachments,
46+
useIntegrationAutoMention,
4647
useMentionMenu,
4748
useMentionTokens,
4849
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
@@ -157,13 +158,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
157158
const overlayRef = useRef<HTMLDivElement>(null)
158159
const plusMenuRef = useRef<PlusMenuHandle>(null)
159160

160-
const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
161-
if (defaultValue && defaultValue !== prevDefaultValue) {
162-
setPrevDefaultValue(defaultValue)
163-
setValue(defaultValue)
164-
} else if (!defaultValue && prevDefaultValue) {
165-
setPrevDefaultValue(defaultValue)
166-
}
161+
const prevDefaultValueRef = useRef(defaultValue)
167162

168163
const files = useFileAttachments({
169164
userId: userId || session?.user?.id,
@@ -321,17 +316,51 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
321316
setSelectedContexts: contextManagement.setSelectedContexts,
322317
})
323318

319+
const integrationAutoMention = useIntegrationAutoMention({
320+
setSelectedContexts: contextManagement.setSelectedContexts,
321+
})
322+
324323
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles
325324

326325
const valueRef = useRef(value)
327326
valueRef.current = value
327+
328+
/**
329+
* Convert integration names on mount for any initial value seeded by
330+
* `defaultValue` or a restored mothership draft. Mid-typing conversion
331+
* is intentionally NOT handled here — the keystroke fast-path in
332+
* `handleInputChange` covers that case via `processChange`, and running
333+
* it on every value change would inject `@` while the user is still
334+
* typing the name and prematurely open the mention menu.
335+
*/
336+
useEffect(() => {
337+
if (!valueRef.current) return
338+
const converted = integrationAutoMention.applyToText(valueRef.current)
339+
if (converted !== valueRef.current) setValue(converted)
340+
// eslint-disable-next-line react-hooks/exhaustive-deps
341+
}, [])
342+
343+
/**
344+
* Sync `value` when the `defaultValue` prop changes post-mount — e.g.
345+
* the user clicks a different template while UserInput is already
346+
* mounted. Mirrors the previously inline render-phase derivation but
347+
* now runs the prompt through `applyToText` so integration names get
348+
* chipified consistently with paste / draft restore flows.
349+
*/
350+
useEffect(() => {
351+
if (defaultValue === prevDefaultValueRef.current) return
352+
prevDefaultValueRef.current = defaultValue
353+
if (defaultValue) setValue(integrationAutoMention.applyToText(defaultValue))
354+
}, [defaultValue, integrationAutoMention.applyToText])
355+
328356
const sttPrefixRef = useRef('')
329357

330358
function handleTranscript(text: string) {
331359
const prefix = sttPrefixRef.current
332360
const newVal = prefix ? `${prefix} ${text}` : text
333-
setValue(newVal)
334-
valueRef.current = newVal
361+
const converted = integrationAutoMention.applyToText(newVal)
362+
setValue(converted)
363+
valueRef.current = converted
335364
}
336365

337366
function handleUsageLimitExceeded() {
@@ -377,7 +406,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
377406
ref,
378407
() => ({
379408
loadQueuedMessage: (msg: QueuedMessage) => {
380-
setValue(msg.content)
409+
setValue(integrationAutoMention.applyToText(msg.content))
381410
const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({
382411
id: a.id,
383412
name: a.filename,
@@ -399,7 +428,12 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
399428
})
400429
},
401430
}),
402-
[files.restoreAttachedFiles, contextManagement.setSelectedContexts, textareaRef]
431+
[
432+
files.restoreAttachedFiles,
433+
contextManagement.setSelectedContexts,
434+
textareaRef,
435+
integrationAutoMention.applyToText,
436+
]
403437
)
404438

405439
useLayoutEffect(() => {
@@ -733,9 +767,12 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
733767
const syncMentionState = useCallback(
734768
(textarea: HTMLTextAreaElement, text: string, caret: number) => {
735769
const active = getActiveMentionAtRef.current(caret, text)
736-
// Treat any whitespace inside the query as a closer — typing a space
737-
// after `@foo` should leave the raw `@foo` text and dismiss the menu.
738-
const isOpenable = active && !/\s/.test(active.query)
770+
// Any word-boundary character inside the query — whitespace, sentence
771+
// punctuation, or brackets — dismisses the menu. The mention token
772+
// is "complete" the moment the user types a non-word character, so
773+
// there's nothing more to query. Mirrors the boundary set the
774+
// integration auto-detector uses for symmetry.
775+
const isOpenable = active && !/[\s.,;:!?(){}[\]"'`/\\<>]/.test(active.query)
739776
if (!isOpenable) {
740777
if (mentionRangeRef.current !== null) {
741778
mentionRangeRef.current = null
@@ -759,12 +796,32 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
759796

760797
const handleInputChange = useCallback(
761798
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
762-
const newValue = e.target.value
763-
const caret = e.target.selectionStart ?? newValue.length
764-
setValue(newValue)
765-
syncMentionState(e.target, newValue, caret)
799+
const previousValue = valueRef.current
800+
const nextValue = e.target.value
801+
802+
let finalValue = nextValue
803+
if (nextValue.length === previousValue.length + 1) {
804+
// Single-char keystroke — synchronous, boundary-triggered.
805+
finalValue = integrationAutoMention.processChange({
806+
textarea: e.target,
807+
previousValue,
808+
nextValue,
809+
})
810+
} else if (nextValue.length > previousValue.length + 1) {
811+
// Multi-char insertion (paste, drag-drop, IME commit) — bulk
812+
// convert all matches and rewrite the textarea via `setRangeText`
813+
// to keep the edit in a single native undo step.
814+
finalValue = integrationAutoMention.applyToText(nextValue)
815+
if (finalValue !== nextValue) {
816+
e.target.setRangeText(finalValue, 0, nextValue.length, 'preserve')
817+
}
818+
}
819+
820+
const caret = e.target.selectionStart ?? finalValue.length
821+
setValue(finalValue)
822+
syncMentionState(e.target, finalValue, caret)
766823
},
767-
[syncMentionState]
824+
[integrationAutoMention.applyToText, integrationAutoMention.processChange, syncMentionState]
768825
)
769826

770827
const handleSelectAdjust = useCallback(() => {

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
type LandingWorkflowSeed,
1515
LandingWorkflowSeedStorage,
1616
} from '@/lib/core/utils/browser-storage'
17+
import {
18+
MOTHERSHIP_SEND_MESSAGE_EVENT,
19+
type MothershipSendMessageDetail,
20+
} from '@/lib/mothership/events'
1721
import { captureEvent } from '@/lib/posthog/client'
1822
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
1923
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
@@ -240,11 +244,11 @@ export function Home({ chatId }: HomeProps = {}) {
240244

241245
useEffect(() => {
242246
const handler = (e: Event) => {
243-
const message = (e as CustomEvent<{ message: string }>).detail?.message
247+
const message = (e as CustomEvent<MothershipSendMessageDetail>).detail?.message
244248
if (message) sendMessage(message)
245249
}
246-
window.addEventListener('mothership-send-message', handler)
247-
return () => window.removeEventListener('mothership-send-message', handler)
250+
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
251+
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
248252
}, [sendMessage])
249253

250254
function resolveResourceFromContext(

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export interface ChatMessageContext {
153153
fileId?: string
154154
folderId?: string
155155
chatId?: string
156+
blockType?: string
156157
}
157158

158159
export interface ChatMessage {

0 commit comments

Comments
 (0)