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
@@ -0,0 +1,88 @@
/**
* @vitest-environment jsdom
*/
import { act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

/** The queued message renders its content through the real block registry. */
vi.unmock('@/blocks/registry')

import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages'

const mounted: Array<() => void> = []

/** jsdom has no ResizeObserver; the queue measures its width with one. */
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}

interface EditableQueueProps {
admissionUnknown: boolean
}

/** The queue as the chat composer drives it: Edit opens the entry, Cancel edit closes it. */
function EditableQueue({ admissionUnknown }: EditableQueueProps) {
const [editingQueuedId, setEditingQueuedId] = useState<string | null>(null)
return (
<QueuedMessages
messageQueue={[{ id: 'held-first', content: 'inspect the workspace', admissionUnknown }]}
editingQueuedId={editingQueuedId}
dispatchingHeadId={null}
onRemove={() => {}}
onSendNow={async () => {}}
onEdit={setEditingQueuedId}
onCancelEdit={() => setEditingQueuedId(null)}
/>
)
}

function renderQueue(admissionUnknown: boolean) {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(<EditableQueue admissionUnknown={admissionUnknown} />)
})
mounted.push(() => {
act(() => root.unmount())
container.remove()
})
const editButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Edit queued message"], button[aria-label^="May already be sent"]'
)
if (!editButton) throw new Error('Edit button not rendered')
/** An entry open for editing shows its Cancel edit control in place of Edit. */
const isEditing = () => container.querySelector('button[aria-label="Cancel edit"]') !== null
return { editButton, isEditing }
}

describe('QueuedMessages edit button', () => {
afterEach(() => {
for (const unmount of mounted.splice(0)) unmount()
})

it('keeps a message that may already be sent from being edited, and says why', () => {
const { editButton, isEditing } = renderQueue(true)

/** Still focusable and hoverable, so its tooltip can explain why. */
expect(editButton.disabled).toBe(false)
expect(editButton.getAttribute('aria-disabled')).toBe('true')
expect(editButton.getAttribute('aria-label')).toBe(
'May already be sent; editing could send a second message'
)
act(() => editButton.click())
expect(isEditing()).toBe(false)
})

it('opens an ordinary queued message for editing', () => {
const { editButton, isEditing } = renderQueue(false)

act(() => editButton.click())
expect(isEditing()).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export function QueuedMessages({
{messageQueue.map((msg) => {
const isEditing = msg.id === editingQueuedId
const isDispatching = msg.id === dispatchingHeadId
const editBlocked = isDispatching || msg.admissionUnknown === true
const editLabel = isDispatching
? 'Sending now'
: msg.admissionUnknown
? 'May already be sent; editing could send a second message'
: 'Edit queued message'
return (
<div
key={msg.id}
Expand Down Expand Up @@ -140,21 +146,24 @@ export function QueuedMessages({
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{/* aria-disabled, not disabled: a disabled button gets no hover or
focus, so the tooltip saying why could never open. */}
<button
aria-label={isDispatching ? 'Sending now' : 'Edit queued message'}
aria-label={editLabel}
aria-disabled={editBlocked}
type='button'
disabled={isDispatching}
onClick={(e) => {
e.stopPropagation()
if (editBlocked) return
onEdit(msg.id)
}}
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover-hover:bg-transparent disabled:hover-hover:text-[var(--text-icon)]'
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)] aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-disabled:hover-hover:bg-transparent aria-disabled:hover-hover:text-[var(--text-icon)]'
>
<Pencil className='size-[13px]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' sideOffset={4}>
{isDispatching ? 'Sending now' : 'Edit queued message'}
{editLabel}
</Tooltip.Content>
</Tooltip.Root>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2053,9 +2053,11 @@ describe('useChat remount send recovery', () => {
})

/**
* The first POST on the new-chat surface never answers; later POSTs open a
* turn in the chat the first message created. The abort endpoint and the
* stream lookup fail, as they would for a Stop that cannot reach the server.
* The first POST on the new-chat surface reaches the server, which admits it,
* but its answer never arrives. A resend under that id gets the server's
* dedupe answer naming the chat it opened; any other POST opens a turn in
* that chat. Until the remount, the abort endpoint and the stream lookup
* fail, as they would for a Stop that cannot reach the server.
*/
function stubFirstPostPendingThenAdmitted() {
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
Expand All @@ -2069,6 +2071,17 @@ describe('useChat remount send recovery', () => {
})
})
}
const firstId = state.postBodies[0].userMessageId
if (state.postBodies.at(-1)?.userMessageId === firstId) {
return Response.json(
{
error: 'This message was already sent.',
activeStreamId: firstId,
chatId: DEDUPED_CHAT_ID,
},
{ status: 409 }
)
}
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
Expand Down Expand Up @@ -2113,6 +2126,13 @@ describe('useChat remount send recovery', () => {
})
await waitFor(() => allQueuedMessages().length === 1)
first.unmount()
/** Held first, as written: the server may already have it under its id. */
expect(
allQueuedMessages().map((message) => [message.content, message.admissionUnknown])
).toEqual([
['inspect the workspace', true],
['follow-up while admission pending', undefined],
])

const second = renderHomeLikeSurface()
await waitFor(() => state.postBodies.length >= 3, 4_000)
Expand All @@ -2136,6 +2156,54 @@ describe('useChat remount send recovery', () => {
* intent: the Stop's POST is left to the server, nothing withdraws it, and the
* next mount sends just the follow-up, once.
*/
/**
* A first message held at the queue head after a remount may already be a
* turn on the server. Editing it would send different text under a new id,
* a second message the user never meant to send.
*/
it('does not let a withdrawn first message held at the queue head be edited', async () => {
const history: MothershipChatHistory = {
id: 'chat-running-while-held',
mode: 'agent',
title: 'Held',
messages: [],
activeStreamId: 'turn-still-running',
resources: [],
}
mockRequestJson.mockImplementation(() => Promise.resolve({ chat: history }))
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).includes('/api/mothership/chat/stream')) {
if (String(input).includes('batch=true')) {
return Response.json({ success: true, events: [], status: 'streaming' })
}
return new Response(new ReadableStream<Uint8Array>(), {
headers: { 'Content-Type': 'text/event-stream' },
})
}
return fetchStub(input, init)
})
useMothershipQueueStore.getState().enqueue(history.id, {
id: 'held-first',
content: 'inspect the workspace',
resumeUserMessageId: 'first-attempt',
admissionUnknown: true,
})
const { getResult } = renderUseChatInChat(history.id, history)
await waitFor(() => getResult().isSending)

let edited: ReturnType<ReturnType<typeof useChat>['editQueuedMessage']>
await act(async () => {
edited = getResult().editQueuedMessage('held-first')
})

expect(edited).toBeUndefined()
expect(getResult().editingQueuedId).toBeNull()
expect(useMothershipQueueStore.getState().queues[history.id]?.[0]).toMatchObject({
content: 'inspect the workspace',
resumeUserMessageId: 'first-attempt',
})
})

it('sends only the follow-up after a failed Stop when the new-chat surface remounts', async () => {
stubFirstPostPendingThenAdmitted()
const first = renderHomeLikeSurface()
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5235,7 +5235,7 @@ export function useChat(
const activeChatKey = chatKeyRef.current
const queue = useMothershipQueueStore.getState().queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE
const msg = queue.find((m) => m.id === id)
if (!msg) return undefined
if (!msg || msg.admissionUnknown) return undefined
// Evict any sessionStorage handoff — a failed prior dispatch may have left
// a pre-edit content snapshot that the recovery effect would otherwise replay.
clearQueuedSendHandoffState(id)
Expand Down Expand Up @@ -5352,6 +5352,7 @@ export function useChat(
id: generateId(),
content: send.content,
resumeUserMessageId: send.userMessageId,
admissionUnknown: true,
...(send.fileAttachments ? { fileAttachments: send.fileAttachments } : {}),
...(send.contexts ? { contexts: send.contexts } : {}),
...(send.requestMode ? { requestMode: send.requestMode } : {}),
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface QueuedMessage {
requestMode?: ChatRequestMode
assistantSearch?: WorkspaceSearchFilters
assistantSearchLevel?: AssistantSearchLevel
/**
* A first message withdrawn before the server answered. The server may
* already hold it as sent, so it goes out exactly as written, under its
* original id, and cannot be edited into a different message.
*/
admissionUnknown?: boolean
}

export const ToolCallStatus = {
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/stores/mothership-queue/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ describe('useMothershipQueueStore', () => {
})

describe('replaceAt', () => {
it('leaves a first message the server may already hold unchanged, with its id', () => {
useMothershipQueueStore.getState().enqueue('chat-A', {
id: 'm1',
content: 'original',
resumeUserMessageId: 'first-attempt',
admissionUnknown: true,
})
useMothershipQueueStore.getState().replaceAt('chat-A', 'm1', { content: 'edited' })
expect(useMothershipQueueStore.getState().queues['chat-A']?.[0]).toMatchObject({
content: 'original',
resumeUserMessageId: 'first-attempt',
})
})

it('editing preserves an unresolved Stop while replacing the prior request identity', () => {
useMothershipQueueStore.getState().enqueue('chat-A', {
id: 'm1',
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/stores/mothership-queue/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export const useMothershipQueueStore = create<MothershipQueueState>()(
const current = state.queues[chatKey] ?? []
const index = current.findIndex((m) => m.id === id)
if (index === -1) return state
/** The server may already hold it as sent; an edit would become a second message. */
if (current[index].admissionUnknown) return state
const next = [...current]
/** Editing changes the request identity, never an unresolved Stop dependency. */
const {
Expand Down
Loading