Skip to content

Commit bcc5ac1

Browse files
author
Theodore Li
committed
Merge branch 'staging' into improvement/merge-chat-ui
2 parents b8ff43c + 59182d5 commit bcc5ac1

File tree

10 files changed

+273
-36
lines changed

10 files changed

+273
-36
lines changed

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type AppSession = {
2121
id?: string
2222
userId?: string
2323
activeOrganizationId?: string
24+
impersonatedBy?: string | null
2425
}
2526
} | null
2627

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,7 @@ export function MessageContent({
413413
return (
414414
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
415415
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
416-
<span className='font-base text-[14px] text-[var(--text-body)]'>
417-
Stopped by user
418-
</span>
416+
<span className='font-base text-[14px] text-[var(--text-body)]'>Stopped</span>
419417
</div>
420418
)
421419
}

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ const STATE_TO_STATUS: Record<string, ToolCallStatus> = {
8585
const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy'])
8686
const RECONNECT_TAIL_ERROR =
8787
'Live reconnect failed before the stream finished. The latest response may be incomplete.'
88+
const CONTINUE_OPTIONS_CONTENT =
89+
'<options>{"continue":{"title":"Continue","description":"Pick up where we left off"}}</options>'
8890

8991
function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
9092
const mapped: ContentBlock = {
@@ -1213,16 +1215,22 @@ export function useChat(
12131215

12141216
if (storedBlocks.length > 0) {
12151217
storedBlocks.push({ type: 'stopped' })
1218+
storedBlocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
12161219
}
12171220

1221+
const persistedContent =
1222+
content && !content.includes('<options>')
1223+
? `${content}\n\n${CONTINUE_OPTIONS_CONTENT}`
1224+
: content
1225+
12181226
try {
12191227
const res = await fetch(stopPathRef.current, {
12201228
method: 'POST',
12211229
headers: { 'Content-Type': 'application/json' },
12221230
body: JSON.stringify({
12231231
chatId,
12241232
streamId,
1225-
content,
1233+
content: persistedContent,
12261234
...(storedBlocks.length > 0 && { contentBlocks: storedBlocks }),
12271235
}),
12281236
})
@@ -1248,6 +1256,50 @@ export function useChat(
12481256
const messagesRef = useRef(messages)
12491257
messagesRef.current = messages
12501258

1259+
const resolveInterruptedToolCalls = useCallback(() => {
1260+
setMessages((prev) => {
1261+
const hasAnyExecuting = prev.some((m) =>
1262+
m.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
1263+
)
1264+
if (!hasAnyExecuting) return prev
1265+
1266+
let lastAssistantIdx = -1
1267+
for (let i = prev.length - 1; i >= 0; i--) {
1268+
if (prev[i].role === 'assistant') {
1269+
lastAssistantIdx = i
1270+
break
1271+
}
1272+
}
1273+
return prev.map((msg, idx) => {
1274+
const hasExecuting = msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')
1275+
const isLastAssistant = idx === lastAssistantIdx
1276+
if (!hasExecuting && !isLastAssistant) return msg
1277+
1278+
const blocks: ContentBlock[] = (msg.contentBlocks ?? []).map((block) => {
1279+
if (block.toolCall?.status !== 'executing') return block
1280+
return {
1281+
...block,
1282+
toolCall: {
1283+
...block.toolCall,
1284+
status: 'cancelled' as const,
1285+
displayTitle: 'Stopped',
1286+
},
1287+
}
1288+
})
1289+
if (isLastAssistant && !blocks.some((b) => b.type === 'stopped')) {
1290+
blocks.push({ type: 'stopped' as const })
1291+
}
1292+
if (
1293+
isLastAssistant &&
1294+
!blocks.some((b) => b.type === 'text' && b.content?.includes('<options>'))
1295+
) {
1296+
blocks.push({ type: 'text', content: CONTINUE_OPTIONS_CONTENT })
1297+
}
1298+
return { ...msg, contentBlocks: blocks.length > 0 ? blocks : msg.contentBlocks }
1299+
})
1300+
})
1301+
}, [])
1302+
12511303
const finalize = useCallback(
12521304
(options?: { error?: boolean }) => {
12531305
sendingRef.current = false
@@ -1262,6 +1314,8 @@ export function useChat(
12621314
}
12631315
}
12641316

1317+
resolveInterruptedToolCalls()
1318+
12651319
if (options?.error) {
12661320
setMessageQueue([])
12671321
return
@@ -1277,7 +1331,7 @@ export function useChat(
12771331
})
12781332
}
12791333
},
1280-
[invalidateChatQueries]
1334+
[invalidateChatQueries, resolveInterruptedToolCalls]
12811335
)
12821336
finalizeRef.current = finalize
12831337

@@ -1435,24 +1489,7 @@ export function useChat(
14351489
sendingRef.current = false
14361490
setIsSending(false)
14371491

1438-
setMessages((prev) =>
1439-
prev.map((msg) => {
1440-
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
1441-
const updated = msg.contentBlocks!.map((block) => {
1442-
if (block.toolCall?.status !== 'executing') return block
1443-
return {
1444-
...block,
1445-
toolCall: {
1446-
...block.toolCall,
1447-
status: 'cancelled' as const,
1448-
displayTitle: 'Stopped by user',
1449-
},
1450-
}
1451-
})
1452-
updated.push({ type: 'stopped' as const })
1453-
return { ...msg, contentBlocks: updated }
1454-
})
1455-
)
1492+
resolveInterruptedToolCalls()
14561493

14571494
if (sid) {
14581495
fetch('/api/copilot/chat/abort', {
@@ -1518,7 +1555,7 @@ export function useChat(
15181555

15191556
reportManualRunToolStop(workflowId, toolCallId).catch(() => {})
15201557
}
1521-
}, [invalidateChatQueries, persistPartialResponse, executionStream])
1558+
}, [invalidateChatQueries, persistPartialResponse, executionStream, resolveInterruptedToolCalls])
15221559

15231560
const removeFromQueue = useCallback((id: string) => {
15241561
messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Banner } from '@/components/emcn'
5+
import { useSession } from '@/lib/auth/auth-client'
6+
import { useStopImpersonating } from '@/hooks/queries/admin-users'
7+
8+
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
9+
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
10+
}
11+
12+
export function ImpersonationBanner() {
13+
const { data: session, isPending } = useSession()
14+
const stopImpersonating = useStopImpersonating()
15+
const [isRedirecting, setIsRedirecting] = useState(false)
16+
const userLabel = session?.user?.name || 'this user'
17+
const userEmail = session?.user?.email
18+
19+
if (isPending || !session?.session?.impersonatedBy) {
20+
return null
21+
}
22+
23+
return (
24+
<Banner
25+
variant='destructive'
26+
text={getImpersonationBannerText(userLabel, userEmail)}
27+
textClassName='text-red-700 dark:text-red-300'
28+
actionLabel={
29+
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
30+
}
31+
actionVariant='destructive'
32+
actionDisabled={stopImpersonating.isPending || isRedirecting}
33+
onAction={() =>
34+
stopImpersonating.mutate(undefined, {
35+
onError: () => {
36+
setIsRedirecting(false)
37+
},
38+
onSuccess: () => {
39+
setIsRedirecting(true)
40+
window.location.assign('/workspace')
41+
},
42+
})
43+
}
44+
/>
45+
)
46+
}

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ToastProvider } from '@/components/emcn'
22
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
3+
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
34
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
45
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
56
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -12,14 +13,17 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
1213
<SettingsLoader />
1314
<ProviderModelsLoader />
1415
<GlobalCommandsProvider>
15-
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
16+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
17+
<ImpersonationBanner />
1618
<WorkspacePermissionsProvider>
17-
<div className='shrink-0' suppressHydrationWarning>
18-
<Sidebar />
19-
</div>
20-
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
21-
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
22-
{children}
19+
<div className='flex min-h-0 flex-1'>
20+
<div className='shrink-0' suppressHydrationWarning>
21+
<Sidebar />
22+
</div>
23+
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
24+
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
25+
{children}
26+
</div>
2327
</div>
2428
</div>
2529
<NavTour />

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
88
import {
99
useAdminUsers,
1010
useBanUser,
11+
useImpersonateUser,
1112
useSetUserRole,
1213
useUnbanUser,
1314
} from '@/hooks/queries/admin-users'
@@ -28,13 +29,16 @@ export function Admin() {
2829
const setUserRole = useSetUserRole()
2930
const banUser = useBanUser()
3031
const unbanUser = useUnbanUser()
32+
const impersonateUser = useImpersonateUser()
3133

3234
const [workflowId, setWorkflowId] = useState('')
3335
const [usersOffset, setUsersOffset] = useState(0)
3436
const [searchInput, setSearchInput] = useState('')
3537
const [searchQuery, setSearchQuery] = useState('')
3638
const [banUserId, setBanUserId] = useState<string | null>(null)
3739
const [banReason, setBanReason] = useState('')
40+
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
41+
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
3842

3943
const {
4044
data: usersData,
@@ -67,6 +71,29 @@ export function Admin() {
6771
)
6872
}
6973

74+
const handleImpersonate = (userId: string) => {
75+
setImpersonationGuardError(null)
76+
if (session?.user?.role !== 'admin') {
77+
setImpersonatingUserId(null)
78+
setImpersonationGuardError('Only admins can impersonate users.')
79+
return
80+
}
81+
82+
setImpersonatingUserId(userId)
83+
impersonateUser.reset()
84+
impersonateUser.mutate(
85+
{ userId },
86+
{
87+
onError: () => {
88+
setImpersonatingUserId(null)
89+
},
90+
onSuccess: () => {
91+
window.location.assign('/workspace')
92+
},
93+
}
94+
)
95+
}
96+
7097
const pendingUserIds = useMemo(() => {
7198
const ids = new Set<string>()
7299
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -75,6 +102,9 @@ export function Admin() {
75102
ids.add((banUser.variables as { userId: string }).userId)
76103
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
77104
ids.add((unbanUser.variables as { userId: string }).userId)
105+
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
106+
ids.add((impersonateUser.variables as { userId: string }).userId)
107+
if (impersonatingUserId) ids.add(impersonatingUserId)
78108
return ids
79109
}, [
80110
setUserRole.isPending,
@@ -83,6 +113,9 @@ export function Admin() {
83113
banUser.variables,
84114
unbanUser.isPending,
85115
unbanUser.variables,
116+
impersonateUser.isPending,
117+
impersonateUser.variables,
118+
impersonatingUserId,
86119
])
87120
return (
88121
<div className='flex h-full flex-col gap-[24px]'>
@@ -152,9 +185,15 @@ export function Admin() {
152185
</p>
153186
)}
154187

155-
{(setUserRole.error || banUser.error || unbanUser.error) && (
188+
{(setUserRole.error ||
189+
banUser.error ||
190+
unbanUser.error ||
191+
impersonateUser.error ||
192+
impersonationGuardError) && (
156193
<p className='text-[13px] text-[var(--text-error)]'>
157-
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
194+
{impersonationGuardError ||
195+
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
196+
?.message ||
158197
'Action failed. Please try again.'}
159198
</p>
160199
)}
@@ -175,7 +214,7 @@ export function Admin() {
175214
<span className='flex-1'>Email</span>
176215
<span className='w-[80px]'>Role</span>
177216
<span className='w-[80px]'>Status</span>
178-
<span className='w-[180px] text-right'>Actions</span>
217+
<span className='w-[250px] text-right'>Actions</span>
179218
</div>
180219

181220
{usersData.users.length === 0 && (
@@ -206,9 +245,22 @@ export function Admin() {
206245
<Badge variant='green'>Active</Badge>
207246
)}
208247
</span>
209-
<span className='flex w-[180px] justify-end gap-[4px]'>
248+
<span className='flex w-[250px] justify-end gap-[4px]'>
210249
{u.id !== session?.user?.id && (
211250
<>
251+
<Button
252+
variant='active'
253+
className='h-[28px] px-[8px] text-[12px]'
254+
onClick={() => handleImpersonate(u.id)}
255+
disabled={pendingUserIds.has(u.id)}
256+
>
257+
{impersonatingUserId === u.id ||
258+
(impersonateUser.isPending &&
259+
(impersonateUser.variables as { userId?: string } | undefined)
260+
?.userId === u.id)
261+
? 'Switching...'
262+
: 'Impersonate'}
263+
</Button>
212264
<Button
213265
variant='active'
214266
className='h-[28px] px-[8px] text-[12px]'

0 commit comments

Comments
 (0)