Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -375,64 +375,81 @@ async function paste(files: File[]) {
return event
}

it('keeps restored queued skills scoped when replacing a draft', async () => {
let restore: () => void = () => {}
function Harness() {
const [value, setValue] = useState('Original draft')
const [contexts, setContexts] = useState<ComponentProps<typeof Composer>['restoredContexts']>()
restore = () => {
setValue('/review fix this')
setContexts([
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
])
it.each([false, true])(
'keeps restored queued skills scoped and warns about lost context (%s)',
async (invalidContext) => {
let restore: () => void = () => {}
function Harness() {
const [value, setValue] = useState('Original draft')
const [contexts, setContexts] =
useState<ComponentProps<typeof Composer>['restoredContexts']>()
restore = () => {
setValue('/review fix this @Browser')
setContexts([
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
...(invalidContext
? [
{
kind: 'browser_tab' as const,
tabId: 'tab-a',
label: 'Browser',
selection: { text: 'Selected passage', title: 'x'.repeat(513) },
},
]
: []),
])
}
const files = useFileAttachments({
userId: 'user-a',
organizationId: 'organization-a',
requestMode: 'agent',
})
return (
<Composer
requestMode='agent'
value={value}
onChange={setValue}
restoredContexts={contexts}
files={files}
isInitialView={false}
isSending={false}
onStop={() => {}}
onSubmit={mocks.submit}
/>
)
}
const files = useFileAttachments({
userId: 'user-a',
organizationId: 'organization-a',
requestMode: 'agent',
})
return (
<Composer
requestMode='agent'
value={value}
onChange={setValue}
restoredContexts={contexts}
files={files}
isInitialView={false}
isSending={false}
onStop={() => {}}
onSubmit={mocks.submit}
/>
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
>
<Harness />
<ToastProvider />
</FeatureFlagsProvider>
</QueryClientProvider>
)
)
}
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
>
<Harness />
</FeatureFlagsProvider>
</QueryClientProvider>
await act(async () => restore())
expect(
container.querySelector<HTMLInputElement | HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
.value
).toBe('\u2003review fix this @Browser')
if (invalidContext) expect(document.body.textContent).toContain('Reattach it before sending.')
await act(async () =>
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
)
)
await act(async () => restore())
expect(
container.querySelector<HTMLInputElement | HTMLTextAreaElement>('[aria-label="Ask Sim"]')!.value
).toBe('\u2003review fix this')
await act(async () =>
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
)
expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this', [
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
])
})
expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this @Browser', [
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
])
}
)

it('shows global built-ins once with no workspace label and submits no invented workspace', async () => {
mocks.workspaces = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn'
import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons'
import { useQueries } from '@tanstack/react-query'
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
import {
ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE,
isAssistantImageType,
Expand Down Expand Up @@ -153,7 +154,11 @@ export function Composer({
useEffect(() => {
if (!restoredContexts) return
history.reset()
editorRef.current.restorePrompt(editorRef.current.getPlainValue(), restoredContexts)
const { hasUnavailableContexts } = editorRef.current.restorePrompt(
editorRef.current.getPlainValue(),
restoredContexts
)
if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
}, [restoredContexts, history.reset])
useChatInputFocus({ textareaRef })
const voice = useVoiceInput({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)

import { toDisplayMessage } from '@/lib/mothership/chat/display-message'
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard'
import {
type UsePromptEditorProps,
Expand Down Expand Up @@ -531,6 +533,51 @@ it('re-chipifies a seeded draft once skills load', () => {
})

describe('sent prompt history', () => {
it.each([
['workspace ownership', { kind: 'file', fileId: 'file-a', workspaceId: 42 }],
[
'conflicting file owner',
{
kind: 'file',
fileId: 'file-a',
workspaceId: 'ws-1',
owner: { entityType: 'workspace', entityId: 'ws-2' },
},
],
['saved table view', { kind: 'table', tableId: 'table-a', viewId: ['view-a'] }],
['empty browser selection', { kind: 'browser_tab', tabId: 'tab-a', selection: { text: '' } }],
[
'terminal selection bounds',
{
kind: 'terminal_tab',
terminalId: 'terminal-a',
selection: { text: 'output', startLine: 10_000_001, endLine: 10_000_002 },
},
],
])('recalls text and valid chips when saved %s is malformed', (_name, context) => {
const valid = { kind: 'file', fileId: 'file-b', label: 'Valid' }
const message = toDisplayMessage(
normalizeMessage({
id: 'stored',
role: 'user',
content: 'Compare @Invalid and @Valid',
contexts: [{ ...context, label: 'Invalid' }, valid],
})
)
const { result, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
try {
act(() => {
expect(
result().restorePrompt(message.content, message.contexts).hasUnavailableContexts
).toBe(true)
})
expect(result().getPlainValue()).toBe('Compare @Invalid and @Valid')
expect(result().getActiveContexts()).toEqual([valid])
} finally {
unmount()
}
})

it('recalls only user messages, walks both ways, and returns to the empty composer', () => {
const history = renderHistoryEditor([
{ id: 'first', role: 'user', content: 'first\nsecond line' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getMothershipAttachmentPreviewUrl,
getMothershipAttachmentUrl,
} from '@/lib/mothership/chat/attachment-preview'
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
Expand Down Expand Up @@ -83,7 +84,7 @@ export function usePromptHistory(props: UsePromptHistoryProps) {
message.contexts
)
if (hasUnavailableContexts) {
toast.info('Some context in this message was not saved. Reattach it before sending.')
toast.info(UNAVAILABLE_CONTEXT_NOTICE)
}
restoreAttachedFiles(files)
editor.pendingCursorRef.current = direction === -1 ? 0 : value.length
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Paperclip, Plus, Slash } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
Expand Down Expand Up @@ -370,7 +371,8 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
loadQueuedMessage: (msg: QueuedMessage) => {
const currentEditor = editorRef.current
history.reset()
currentEditor.restorePrompt(msg.content, msg.contexts)
const { hasUnavailableContexts } = currentEditor.restorePrompt(msg.content, msg.contexts)
if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({
id: a.id,
name: a.filename,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/** @vitest-environment jsdom */
import { beforeEach, expect, it } from 'vitest'
import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits'
import { STREAM_STORAGE_KEY } from '@/lib/mothership/constants'
import { readQueuedSendHandoffState, writeQueuedSendHandoffState } from './send-handoff'
import {
readQueuedSendHandoffState,
writeQueuedSendHandoffState,
} from '@/app/workspace/[workspaceId]/home/hooks/send-handoff'

beforeEach(() => sessionStorage.clear())
const seed = () => ({
Expand Down Expand Up @@ -33,3 +37,76 @@ it('rejects invalid levels and preserves an explicit Build mode', () => {
writeQueuedSendHandoffState({ ...seed(), requestMode: 'agent' })
expect(readQueuedSendHandoffState()).toMatchObject({ requestMode: 'agent' })
})

it('keeps lost-context evidence when a recovered handoff is written again', () => {
const validContext = { kind: 'docs' as const, label: 'Docs' }
writeQueuedSendHandoffState({
...seed(),
contexts: [
validContext,
{
kind: 'browser_tab',
tabId: 'tab-a',
label: 'Browser',
selection: { text: 'Selected passage', title: 'x'.repeat(513) },
},
],
})
const recovered = readQueuedSendHandoffState()
expect(recovered).toMatchObject({ contexts: [validContext], hasUnavailableContexts: true })
if (!recovered) throw new Error('Expected a recoverable handoff')
writeQueuedSendHandoffState(recovered)
expect(readQueuedSendHandoffState()).toMatchObject({
contexts: [validContext],
hasUnavailableContexts: true,
})
})

it.each([null, {}, 'invalid'])(
'keeps the message for review when stored contexts are malformed (%s)',
(contexts) => {
sessionStorage.setItem(
`${STREAM_STORAGE_KEY}:queued-send-handoff`,
JSON.stringify({ ...seed(), contexts })
)
expect(readQueuedSendHandoffState()).toMatchObject({
message: 'Search',
hasUnavailableContexts: true,
})
}
)

it.each([100, 101])(
'recovers %s contexts within the send limit without losing review state',
(count) => {
const contexts = Array.from({ length: count }, (_, index) => ({
kind: 'file' as const,
fileId: `file-${index}`,
label: `File ${index}`,
}))
writeQueuedSendHandoffState({ ...seed(), contexts })
const recovered = readQueuedSendHandoffState()
expect(recovered?.contexts).toEqual(contexts.slice(0, MAX_CHAT_CONTEXTS))
expect(recovered?.message).toBe('Search')
expect(recovered?.hasUnavailableContexts).toBe(count > MAX_CHAT_CONTEXTS ? true : undefined)
if (!recovered) throw new Error('Expected a recoverable handoff')
writeQueuedSendHandoffState(recovered)
expect(readQueuedSendHandoffState()).toEqual(recovered)
}
)

it('keeps a full limit of valid contexts when an earlier stored context is invalid', () => {
const contexts = Array.from({ length: MAX_CHAT_CONTEXTS }, (_, index) => ({
kind: 'file' as const,
fileId: `file-${index}`,
label: `File ${index}`,
}))
sessionStorage.setItem(
`${STREAM_STORAGE_KEY}:queued-send-handoff`,
JSON.stringify({
...seed(),
contexts: [{ kind: 'workspace', label: 'Missing address' }, ...contexts],
})
)
expect(readQueuedSendHandoffState()).toMatchObject({ contexts, hasUnavailableContexts: true })
})
15 changes: 12 additions & 3 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isChatContext } from '@/lib/mothership/chat/context'
import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits'
/**
* Queued-send handoff (revamp M6 extraction, behavior-preserving): the sessionStorage
* claim machinery that carries a queued outgoing message across a chat-scope handoff —
Expand Down Expand Up @@ -35,6 +36,7 @@ export interface QueuedSendHandoffState extends QueuedSendHandoffSeed {
message: string
fileAttachments?: FileAttachmentForApi[]
contexts?: ChatContext[]
hasUnavailableContexts?: boolean
requestMode?: ChatRequestMode
assistantSearch?: WorkspaceSearchFilters
assistantSearchLevel?: AssistantSearchLevel
Expand Down Expand Up @@ -108,6 +110,14 @@ export function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
const assistantSearch = workspaceSearchFiltersSchema.safeParse(parsed.assistantSearch ?? {})
if (!assistantSearch.success) return null

const contexts = Array.isArray(parsed.contexts)
Comment thread
waleedlatif1 marked this conversation as resolved.
? parsed.contexts.filter(isChatContext).slice(0, MAX_CHAT_CONTEXTS)
: undefined
const hasUnavailableContexts =
parsed.hasUnavailableContexts === true ||
(parsed.contexts !== undefined &&
(!Array.isArray(parsed.contexts) || contexts?.length !== parsed.contexts.length))

return {
id: parsed.id,
...(chatId ? { chatId } : {}),
Expand All @@ -123,9 +133,8 @@ export function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
...(Array.isArray(parsed.fileAttachments)
? { fileAttachments: parsed.fileAttachments.filter(isFileAttachmentForApi) }
: {}),
...(Array.isArray(parsed.contexts)
? { contexts: parsed.contexts.filter(isChatContext) }
: {}),
...(contexts ? { contexts } : {}),
...(hasUnavailableContexts ? { hasUnavailableContexts: true } : {}),
...(parsed.requestMode === 'assistant' ||
parsed.requestMode === 'agent' ||
parsed.requestMode === 'plan'
Expand Down
Loading
Loading