Skip to content

Commit aa51b01

Browse files
committed
fix(chat): keep an answer's end-of-conversation behavior under trailing rows
A steered message or a message shown as not delivered renders after the answer, and the row counted as last. A running answer with no output yet rendered nothing and lost its thinking indicator, and the last answer's choices stopped responding. A reveal that outlived its stream also never reported settling. The answer's last-row behavior now reads the last row that is not on its way into a turn or shown as not delivered.
1 parent 861eea1 commit aa51b01

3 files changed

Lines changed: 159 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ import {
6565
} from '@/app/workspace/[workspaceId]/home/components/user-input'
6666
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
6767
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
68-
import { findLiveResponseId } from '@/app/workspace/[workspaceId]/home/hooks/steered-messages'
68+
import {
69+
findLastConversationIndex,
70+
findLiveResponseId,
71+
} from '@/app/workspace/[workspaceId]/home/hooks/steered-messages'
6972
import {
7073
fetchWorkspaceResourceRef,
7174
readCachedWorkspaceResourceRef,
@@ -746,6 +749,7 @@ export function MothershipChat({
746749
}
747750
}
748751
const lastIndex = messages.length - 1
752+
const lastConversationIndex = findLastConversationIndex(messages)
749753
const lastRowKey = lastIndex >= 0 ? rowKeyByIndex[lastIndex] : undefined
750754
useEffect(() => {
751755
setLastRowAnimating(false)
@@ -975,7 +979,7 @@ export function MothershipChat({
975979
{virtualItems.map((virtualItem) => {
976980
const index = virtualItem.index
977981
const msg = messages[index]
978-
const isLast = index === lastIndex
982+
const isLast = index === lastConversationIndex
979983
return (
980984
<div
981985
key={virtualItem.key}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Messages on their way into a turn, or shown as not delivered, render after its answer. Failure
5+
* modes: the running answer loses its thinking indicator because a steered message trails it (an
6+
* empty one disappears altogether); the last answer's choices stop responding because a message
7+
* not delivered trails it; a real next message leaves the old answer's choices live.
8+
*/
9+
import { act } from 'react'
10+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
11+
import { libDesktopMock } from '@sim/testing/mocks/lib-desktop.mock'
12+
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
13+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
14+
import { createRoot, type Root } from 'react-dom/client'
15+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16+
import { FeatureFlagsProvider } from '@/lib/core/config/feature-flags-context'
17+
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat'
18+
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
19+
20+
vi.mock('next/navigation', () => nextNavigationMock)
21+
vi.mock('@/lib/desktop', () => libDesktopMock)
22+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
23+
24+
const owner: ChatMessage = { id: 'turn', role: 'user', content: 'Count' }
25+
const steered: ChatMessage = {
26+
id: 'steer',
27+
role: 'user',
28+
content: 'Stop at two',
29+
steering: { streamId: 'turn' },
30+
delivery: 'sending',
31+
}
32+
const notDelivered: ChatMessage = {
33+
id: 'failed',
34+
role: 'user',
35+
content: 'Then summarize',
36+
delivery: 'failed',
37+
}
38+
const choices: ChatMessage = {
39+
id: 'answer',
40+
role: 'assistant',
41+
content: '',
42+
contentBlocks: [{ type: 'options', options: [{ id: '1', label: 'Pick this one' }] }],
43+
}
44+
45+
let root: Root
46+
let container: HTMLDivElement
47+
let client: QueryClient
48+
const onSubmit = vi.fn()
49+
50+
beforeEach(() => {
51+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
52+
vi.stubGlobal(
53+
'ResizeObserver',
54+
class {
55+
observe() {}
56+
unobserve() {}
57+
disconnect() {}
58+
}
59+
)
60+
vi.stubGlobal('matchMedia', () => ({
61+
matches: true,
62+
addEventListener: vi.fn(),
63+
removeEventListener: vi.fn(),
64+
}))
65+
/* The virtualizer renders nothing into a viewport with no size. */
66+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(2_000)
67+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800)
68+
onSubmit.mockReset()
69+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
70+
container = document.createElement('div')
71+
document.body.append(container)
72+
root = createRoot(container)
73+
})
74+
75+
afterEach(async () => {
76+
await act(async () => root.unmount())
77+
client.clear()
78+
container.remove()
79+
vi.restoreAllMocks()
80+
})
81+
82+
async function renderChat(messages: ChatMessage[], isSending: boolean) {
83+
await act(async () =>
84+
root.render(
85+
<QueryClientProvider client={client}>
86+
<FeatureFlagsProvider
87+
flags={{
88+
dashboards: false,
89+
'workflow-tests': false,
90+
changelog: false,
91+
'mothership-model-selector': false,
92+
'mothership-plan-mode': false,
93+
'mothership-computer-use': false,
94+
}}
95+
>
96+
<MothershipChat
97+
messages={messages}
98+
isSending={isSending}
99+
onSubmit={onSubmit}
100+
onStopGeneration={vi.fn()}
101+
composer={<div />}
102+
/>
103+
</FeatureFlagsProvider>
104+
</QueryClientProvider>
105+
)
106+
)
107+
}
108+
109+
function findOption(): HTMLElement {
110+
const option = [...container.querySelectorAll('button')].find((button) =>
111+
button.textContent?.includes('Pick this one')
112+
)
113+
if (!option) throw new Error('The answer rendered no choice')
114+
return option
115+
}
116+
117+
describe('rows trailing an answer', () => {
118+
it('keeps the thinking indicator on a running answer a steered message trails', async () => {
119+
await renderChat(
120+
[owner, { id: 'live', role: 'assistant', content: '', contentBlocks: [] }, steered],
121+
true
122+
)
123+
expect(container.textContent).toContain('Stop at two')
124+
expect(container.textContent).toContain('Thinking')
125+
})
126+
127+
it('keeps the last answer’s choices live under a message shown as not delivered', async () => {
128+
await renderChat([owner, choices, notDelivered], false)
129+
await act(async () => findOption().click())
130+
expect(onSubmit).toHaveBeenCalledOnce()
131+
})
132+
133+
it('leaves an answer’s choices dead once a real next message follows it', async () => {
134+
await renderChat(
135+
[owner, choices, { id: 'next', role: 'user', content: 'Something else' }],
136+
false
137+
)
138+
await act(async () => findOption().click())
139+
expect(onSubmit).not.toHaveBeenCalled()
140+
})
141+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/steered-messages.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export function findLiveResponseId(messages: readonly ChatMessage[]): string | u
7878
return undefined
7979
}
8080

81+
/**
82+
* The last row of the conversation proper: messages on their way into a turn, or
83+
* shown as not delivered, trail it without being its end. The answer here is the one
84+
* still thinking, and the one whose choices can still be picked.
85+
*/
86+
export function findLastConversationIndex(messages: readonly ChatMessage[]): number {
87+
for (let index = messages.length - 1; index >= 0; index--) {
88+
if (!messages[index].steering && !messages[index].delivery) return index
89+
}
90+
return -1
91+
}
92+
8193
const ANSWER_SEGMENT_MARKER = ':before:'
8294

8395
/**

0 commit comments

Comments
 (0)