Skip to content

Commit 04f524c

Browse files
authored
feat(chat): recall sent messages from composer (#9289)
* feat(chat): recall sent messages from composer * fix(chat): preserve recalled chips and caret navigation * fix(chat): validate saved integration labels before recall
1 parent 6e6243b commit 04f524c

15 files changed

Lines changed: 569 additions & 173 deletions

File tree

‎apps/sim/app/o/[organizationId]/components/search-input-bar.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type ClipboardEventHandler,
3+
type KeyboardEventHandler,
34
type ReactNode,
45
type RefObject,
56
useLayoutEffect,
@@ -18,6 +19,7 @@ interface SearchInputBarProps {
1819
onChange: (value: string) => void
1920
onSubmit: () => void
2021
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>
22+
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>
2123
placeholder?: string
2224
'aria-label'?: string
2325
leadingControls?: ReactNode
@@ -37,6 +39,7 @@ export function SearchInputBar({
3739
onChange,
3840
onSubmit,
3941
onPaste,
42+
onKeyDown,
4043
placeholder = 'Search your sources',
4144
'aria-label': ariaLabel = 'Search your sources',
4245
selectionControl,
@@ -108,6 +111,8 @@ export function SearchInputBar({
108111
maxHeight={200}
109112
onChange={(event) => onChange(event.target.value)}
110113
onKeyDown={(event) => {
114+
onKeyDown?.(event)
115+
if (event.defaultPrevented) return
111116
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
112117
event.preventDefault()
113118
onSubmit()

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { useEffect, useRef } from 'react'
44
import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn'
55
import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons'
6-
import { escapeRegExp } from '@sim/utils/string'
76
import { useQueries } from '@tanstack/react-query'
87
import {
98
ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE,
@@ -25,9 +24,9 @@ import {
2524
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor'
2625
import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options'
2726
import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut'
28-
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
27+
import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
28+
import type { ChatMessage, ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
2929
import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
30-
import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
3130
import { getSkillsQueryOptions } from '@/hooks/queries/skills'
3231
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
3332
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
@@ -49,6 +48,8 @@ interface ComposerProps {
4948
restoredContexts?: ChatContext[]
5049
onSubmit: (text: string, contexts?: ChatContext[]) => void
5150
onSendQueuedHead?: () => void
51+
onEditQueuedTail?: () => boolean
52+
messages?: readonly ChatMessage[]
5253
onStop: () => void
5354
}
5455

@@ -69,6 +70,8 @@ export function Composer({
6970
onChange,
7071
onSubmit,
7172
onSendQueuedHead,
73+
onEditQueuedTail,
74+
messages,
7275
restoredContexts,
7376
onStop,
7477
}: ComposerProps) {
@@ -100,6 +103,12 @@ export function Composer({
100103
initialContexts: restoredContexts,
101104
onPasteFiles: files.processFiles,
102105
})
106+
const history = usePromptHistory({
107+
editor,
108+
attachedFiles: files.attachedFiles,
109+
restoreAttachedFiles: files.restoreAttachedFiles,
110+
getMessages: () => messages ?? [],
111+
})
103112
const { textareaRef } = editor
104113
const searchBlocked =
105114
editor.getActiveContexts().length > 0 ||
@@ -143,19 +152,9 @@ export function Composer({
143152
}, [editor.value, editor.contexts, onChange])
144153
useEffect(() => {
145154
if (!restoredContexts) return
146-
// A queued skill may belong to a workspace whose picker has never opened here.
147-
// Restore its existing chip from the saved context, without rediscovering it.
148-
let restoredText = editorRef.current.getValue()
149-
for (const context of restoredContexts) {
150-
if (context.kind !== 'skill') continue
151-
restoredText = restoredText.replace(
152-
new RegExp(`(^|\\s)/${escapeRegExp(context.label)}(?=\\s|$)`, 'g'),
153-
`$1${SKILL_CHIP_TRIGGER}${context.label}`
154-
)
155-
}
156-
editorRef.current.setValue(restoredText, { chipify: false })
157-
editorRef.current.setContexts(restoredContexts)
158-
}, [restoredContexts])
155+
history.reset()
156+
editorRef.current.restorePrompt(editorRef.current.getPlainValue(), restoredContexts)
157+
}, [restoredContexts, history.reset])
159158
useChatInputFocus({ textareaRef })
160159
const voice = useVoiceInput({
161160
organizationId: organization.id,
@@ -184,6 +183,7 @@ export function Composer({
184183
voice.resetTranscript()
185184
const contexts = imagesOnly ? [] : editor.getActiveContexts()
186185
onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined)
186+
history.reset()
187187
editor.clear()
188188
}
189189

@@ -207,12 +207,18 @@ export function Composer({
207207
</Tooltip.Root>
208208
)
209209

210+
const recallQueuedTail = () => {
211+
if (attachedFilesRef.current.length > 0) return false
212+
return onEditQueuedTail?.() ?? false
213+
}
210214
const promptEditor = (
211215
<PromptEditor
212216
editor={editor}
213217
placeholder={placeholder}
214218
aria-label='Ask Sim'
215219
onSubmit={submit}
220+
onNavigateHistory={history.navigate}
221+
onArrowUpOnEmpty={recallQueuedTail}
216222
className={cn(
217223
'max-h-[200px] max-md:max-h-[min(200px,calc(var(--mobile-viewport-height,100dvh)*0.25))]',
218224
isInitialView && 'min-h-[56px] max-md:min-h-0'
@@ -308,6 +314,12 @@ export function Composer({
308314
onChange={(text) => editor.setValue(text)}
309315
onSubmit={submit}
310316
onPaste={editor.handlePaste}
317+
onKeyDown={(event) =>
318+
editor.handleKeyDown(event, {
319+
onNavigateHistory: history.navigate,
320+
onArrowUpOnEmpty: recallQueuedTail,
321+
})
322+
}
311323
placeholder={placeholder}
312324
aria-label='Ask Sim'
313325
leadingControls={leadingControls}

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,32 @@ function OrganizationHomeContent({
299299
files.clearAttachedFiles()
300300
}
301301

302+
const editQueuedMessage = (id: string) => {
303+
const queued = chat.editQueuedMessage(id)
304+
if (queued) {
305+
const queuedMode = queued.requestMode ?? requestMode
306+
setSelectedMode(queuedMode)
307+
setDraft(queued.content, queued.contexts)
308+
setRestoredContexts(queued.contexts ?? [])
309+
files.restoreAttachedFiles(
310+
(queued.fileAttachments ?? []).map((file) => ({
311+
id: file.id,
312+
key: file.key,
313+
name: file.filename,
314+
type: file.media_type,
315+
size: file.size,
316+
path:
317+
file.path ||
318+
getMothershipAttachmentPreviewUrl(file) ||
319+
getMothershipAttachmentUrl(file),
320+
previewUrl: getMothershipAttachmentPreviewUrl(file),
321+
uploading: false,
322+
}))
323+
)
324+
}
325+
return queued
326+
}
327+
302328
const composer =
303329
requestMode !== 'assistant' && !canBuild ? (
304330
<div className='px-4 py-3 text-[var(--text-muted)] text-sm'>
@@ -317,6 +343,11 @@ function OrganizationHomeContent({
317343
onModeChange={canSelectMode ? changeMode : undefined}
318344
value={draft}
319345
restoredContexts={restoredContexts}
346+
messages={chat.messages}
347+
onEditQueuedTail={() => {
348+
const tail = chat.messageQueue.at(-1)
349+
return tail ? Boolean(editQueuedMessage(tail.id)) : false
350+
}}
320351
files={files}
321352
isInitialView={!hasChat}
322353
isSending={chat.isSending || chat.isReconnecting}
@@ -357,31 +388,7 @@ function OrganizationHomeContent({
357388
dispatchingHeadId={chat.dispatchingHeadId}
358389
onRemoveQueuedMessage={chat.removeFromQueue}
359390
onSendQueuedMessage={chat.sendNow}
360-
onEditQueuedMessage={(id) => {
361-
const queued = chat.editQueuedMessage(id)
362-
if (queued) {
363-
const queuedMode = queued.requestMode ?? requestMode
364-
setSelectedMode(queuedMode)
365-
setDraft(queued.content, queued.contexts)
366-
setRestoredContexts(queued.contexts ?? [])
367-
files.restoreAttachedFiles(
368-
(queued.fileAttachments ?? []).map((file) => ({
369-
id: file.id,
370-
key: file.key,
371-
name: file.filename,
372-
type: file.media_type,
373-
size: file.size,
374-
path:
375-
file.path ||
376-
getMothershipAttachmentPreviewUrl(file) ||
377-
getMothershipAttachmentUrl(file),
378-
previewUrl: getMothershipAttachmentPreviewUrl(file),
379-
uploading: false,
380-
}))
381-
)
382-
}
383-
return queued
384-
}}
391+
onEditQueuedMessage={editQueuedMessage}
385392
onCancelQueueEdit={chat.cancelQueueEdit}
386393
userId={session?.user?.id}
387394
chatId={chat.resolvedChatId}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ export function MothershipChat({
396396
* starving the main thread on every streaming token.
397397
*/
398398
const messages = useDeferredValue(messagesProp)
399+
const historyMessagesRef = useRef(messagesProp)
400+
historyMessagesRef.current = messagesProp
401+
const getHistoryMessages = useCallback(() => historyMessagesRef.current, [])
399402
const [lastRowAnimating, setLastRowAnimating] = useState(false)
400403
const containerRef = useRef<HTMLDivElement>(null)
401404
const scrollElementRef = useRef<HTMLDivElement | null>(null)
@@ -779,16 +782,17 @@ export function MothershipChat({
779782
const handleEditQueued = useCallback(
780783
(id: string) => {
781784
const msg = onEditQueuedMessage(id)
782-
if (!msg) return
783-
userInputRef.current?.loadQueuedMessage(msg)
785+
if (!msg || !userInputRef.current) return false
786+
userInputRef.current.loadQueuedMessage(msg)
787+
return true
784788
},
785789
[onEditQueuedMessage, userInputRef]
786790
)
787791

788792
const handleEditQueuedTail = useCallback(() => {
789793
const tail = messageQueueRef.current[messageQueueRef.current.length - 1]
790-
if (!tail) return
791-
handleEditQueued(tail.id)
794+
if (!tail) return false
795+
return handleEditQueued(tail.id)
792796
}, [handleEditQueued])
793797

794798
/**
@@ -988,6 +992,7 @@ export function MothershipChat({
988992
onSendQueuedHead={handleSendQueuedHead}
989993
onEditQueuedTail={handleEditQueuedTail}
990994
draftScopeKey={draftScopeKey}
995+
getMessages={getHistoryMessages}
991996
/>
992997
))}
993998
</div>

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function PromptEditor({
7171
'aria-label': ariaLabel,
7272
onSubmit,
7373
onArrowUpOnEmpty,
74+
onNavigateHistory,
7475
}: PromptEditorProps) {
7576
const { textareaRef, value } = editor
7677
const scrollerRef = useRef<HTMLDivElement>(null)
@@ -198,7 +199,9 @@ export function PromptEditor({
198199
readOnly={readOnly}
199200
onChange={readOnly ? undefined : editor.handleInputChange}
200201
onKeyDown={
201-
readOnly ? undefined : (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty })
202+
readOnly
203+
? undefined
204+
: (e) => editor.handleKeyDown(e, { onSubmit, onArrowUpOnEmpty, onNavigateHistory })
202205
}
203206
onFocus={readOnly ? undefined : () => setHasFocused(true)}
204207
onPaste={readOnly ? undefined : editor.handlePaste}

0 commit comments

Comments
 (0)