Skip to content

Commit a730c86

Browse files
committed
fix few more ui/ux bugs
1 parent 139fc23 commit a730c86

8 files changed

Lines changed: 652 additions & 47 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const PlusMenuDropdown = React.memo(
8989
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
9090
)
9191
}, [isMention, mentionQuery, search, availableResources])
92+
const isRootMenu = !isMention && filteredItems === null
9293

9394
const filteredItemsRef = useRef(filteredItems)
9495
filteredItemsRef.current = filteredItems
@@ -248,6 +249,7 @@ export const PlusMenuDropdown = React.memo(
248249
collisionPadding={8}
249250
className={cn(
250251
'flex flex-col overflow-hidden',
252+
isRootMenu && 'max-h-none',
251253
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
252254
// to its content via the emcn DropdownMenuContent default max-w.
253255
// Mention mode renders resource names directly, so widen for breathing room.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
6+
import {
7+
MothershipStreamV1EventType,
8+
MothershipStreamV1ToolPhase,
9+
} from '@/lib/copilot/generated/mothership-stream-v1'
10+
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
11+
import {
12+
getReplayCompletedWorkflowToolCallIds,
13+
reconcileLiveAssistantTurn,
14+
selectReconnectReplayState,
15+
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
16+
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
17+
18+
vi.mock('next/navigation', () => ({
19+
usePathname: () => '/workspace/workspace-1/home',
20+
useRouter: () => ({
21+
push: vi.fn(),
22+
replace: vi.fn(),
23+
refresh: vi.fn(),
24+
}),
25+
}))
26+
27+
function userMessage(id: string): PersistedMessage {
28+
return {
29+
id,
30+
role: 'user',
31+
content: 'Question',
32+
timestamp: '2026-05-08T00:00:00.000Z',
33+
}
34+
}
35+
36+
function assistantMessage(id: string, content: string): PersistedMessage {
37+
return {
38+
id,
39+
role: 'assistant',
40+
content,
41+
timestamp: '2026-05-08T00:00:01.000Z',
42+
}
43+
}
44+
45+
function toolBatchEvent(
46+
eventId: number,
47+
toolCallId: string,
48+
toolName: string,
49+
phase: MothershipStreamV1ToolPhase
50+
): StreamBatchEvent {
51+
return {
52+
eventId,
53+
streamId: 'stream-1',
54+
event: {
55+
v: 1,
56+
seq: eventId,
57+
ts: '2026-05-08T00:00:00.000Z',
58+
type: MothershipStreamV1EventType.tool,
59+
stream: { streamId: 'stream-1' },
60+
payload: {
61+
phase,
62+
toolCallId,
63+
toolName,
64+
},
65+
},
66+
} as StreamBatchEvent
67+
}
68+
69+
describe('reconcileLiveAssistantTurn', () => {
70+
it('replaces the live assistant for the active stream owner', () => {
71+
const liveAssistant = assistantMessage('live-assistant:stream-1', 'updated')
72+
const messages = [userMessage('stream-1'), assistantMessage('live-assistant:stream-1', 'old')]
73+
74+
const result = reconcileLiveAssistantTurn({
75+
messages,
76+
streamId: 'stream-1',
77+
liveAssistant,
78+
activeStreamId: 'stream-1',
79+
})
80+
81+
expect(result).toEqual([userMessage('stream-1'), liveAssistant])
82+
})
83+
84+
it('replaces the generated assistant after the owner while the stream is active', () => {
85+
const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content')
86+
87+
const result = reconcileLiveAssistantTurn({
88+
messages: [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')],
89+
streamId: 'stream-1',
90+
liveAssistant,
91+
activeStreamId: 'stream-1',
92+
})
93+
94+
expect(result).toEqual([userMessage('stream-1'), liveAssistant])
95+
})
96+
97+
it('leaves a terminal persisted assistant alone when the stream is no longer active', () => {
98+
const messages = [userMessage('stream-1'), assistantMessage('final-1', 'persisted content')]
99+
100+
const result = reconcileLiveAssistantTurn({
101+
messages,
102+
streamId: 'stream-1',
103+
liveAssistant: assistantMessage('live-assistant:stream-1', 'stale live content'),
104+
activeStreamId: null,
105+
})
106+
107+
expect(result).toBe(messages)
108+
})
109+
110+
it('removes stale live assistant duplicates when a terminal persisted assistant exists', () => {
111+
const finalAssistant = assistantMessage('final-1', 'persisted content')
112+
const staleLiveAssistant = assistantMessage('live-assistant:stream-1', 'stale live content')
113+
114+
const result = reconcileLiveAssistantTurn({
115+
messages: [
116+
userMessage('stream-1'),
117+
finalAssistant,
118+
userMessage('next-user'),
119+
staleLiveAssistant,
120+
],
121+
streamId: 'stream-1',
122+
liveAssistant: staleLiveAssistant,
123+
activeStreamId: null,
124+
})
125+
126+
expect(result).toEqual([userMessage('stream-1'), finalAssistant, userMessage('next-user')])
127+
})
128+
129+
it('inserts the live assistant immediately after its owner', () => {
130+
const nextUser = userMessage('next-user')
131+
const liveAssistant = assistantMessage('live-assistant:stream-1', 'live content')
132+
133+
const result = reconcileLiveAssistantTurn({
134+
messages: [userMessage('stream-1'), nextUser],
135+
streamId: 'stream-1',
136+
liveAssistant,
137+
activeStreamId: 'stream-1',
138+
})
139+
140+
expect(result).toEqual([userMessage('stream-1'), liveAssistant, nextUser])
141+
})
142+
})
143+
144+
describe('selectReconnectReplayState', () => {
145+
it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => {
146+
const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' }
147+
148+
const result = selectReconnectReplayState({
149+
afterCursor: '4',
150+
cachedLiveAssistant: {
151+
content: 'Hello world',
152+
contentBlocks: [cachedBlock],
153+
},
154+
currentContent: 'Hello',
155+
currentBlocks: [],
156+
})
157+
158+
expect(result).toEqual({
159+
afterCursor: '4',
160+
content: 'Hello world',
161+
contentBlocks: [cachedBlock],
162+
preserveExistingState: true,
163+
source: 'cache',
164+
})
165+
})
166+
167+
it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => {
168+
const result = selectReconnectReplayState({
169+
afterCursor: '4',
170+
cachedLiveAssistant: null,
171+
currentContent: '',
172+
currentBlocks: [],
173+
})
174+
175+
expect(result).toEqual({
176+
afterCursor: '0',
177+
content: '',
178+
contentBlocks: [],
179+
preserveExistingState: false,
180+
source: 'reset',
181+
})
182+
})
183+
184+
it('resets when cached live content diverges from the local prefix', () => {
185+
const result = selectReconnectReplayState({
186+
afterCursor: '4',
187+
cachedLiveAssistant: {
188+
content: 'Goodbye world',
189+
contentBlocks: [{ type: 'text', content: 'Goodbye world' }],
190+
},
191+
currentContent: 'Hello',
192+
currentBlocks: [{ type: 'text', content: 'Hello' }],
193+
})
194+
195+
expect(result).toEqual({
196+
afterCursor: '0',
197+
content: '',
198+
contentBlocks: [],
199+
preserveExistingState: false,
200+
source: 'reset',
201+
})
202+
})
203+
204+
it('resets current state for cursor zero replay', () => {
205+
const currentBlock: ContentBlock = { type: 'text', content: 'Hello' }
206+
207+
const result = selectReconnectReplayState({
208+
afterCursor: '0',
209+
cachedLiveAssistant: null,
210+
currentContent: 'Hello',
211+
currentBlocks: [currentBlock],
212+
})
213+
214+
expect(result).toEqual({
215+
afterCursor: '0',
216+
content: '',
217+
contentBlocks: [],
218+
preserveExistingState: false,
219+
source: 'reset',
220+
})
221+
})
222+
})
223+
224+
describe('getReplayCompletedWorkflowToolCallIds', () => {
225+
it('suppresses only workflow tool starts that already have results in the replay batch', () => {
226+
const result = getReplayCompletedWorkflowToolCallIds([
227+
toolBatchEvent(1, 'workflow-active', 'run_workflow', MothershipStreamV1ToolPhase.call),
228+
toolBatchEvent(2, 'search-complete', 'tool_search', MothershipStreamV1ToolPhase.result),
229+
toolBatchEvent(3, 'workflow-complete', 'run_workflow', MothershipStreamV1ToolPhase.result),
230+
])
231+
232+
expect(result).toEqual(new Set(['workflow-complete']))
233+
})
234+
})

0 commit comments

Comments
 (0)