Skip to content

Commit 8a4c161

Browse files
authored
feat(home): resizable chat/resource panel divider (#3648)
* feat(home): resizable chat/resource panel divider * fix(home): address PR review comments - Remove aria-hidden from resize handle outer div so separator role is visible to AT - Add viewport-resize re-clamping in useMothershipResize to prevent panel exceeding max % after browser window narrows - Change default MothershipView width from 60% to 50% * refactor(home): eradicate useEffect anti-patterns per you-might-not-need-an-effect - use-chat: remove messageQueue→ref sync Effect; inline assignment like other refs - use-chat: replace activeResourceId selection Effect with useMemo (derived value, avoids extra re-render cycle; activeResourceIdRef now tracks effective value for API payloads) - use-chat: replace 3x useLayoutEffect ref-sync (processSSEStream, finalize, sendMessage) with direct render-phase assignment — consistent with existing resourcesRef pattern - user-input: fold onEditValueConsumed callback into existing render-phase guard; remove Effect - home: move isResourceAnimatingIn 400ms timer into expandResource/handleResourceEvent event handlers where setIsResourceAnimatingIn(true) is called; remove reactive Effect watcher * fix(home): revert default width to 60%, reduce max resize to 63% * improvement(react): replace useEffect anti-patterns with better React primitives * improvement(react): replace useEffect anti-patterns with better React primitives * improvement(home): use pointer events for resize handle (touch + mouse support) * fix(autosave): store idle-reset timer ref to prevent status corruption on rapid saves * fix(home): move onEditValueConsumed call out of render phase into useEffect * fix(home): add pointercancel handler; fix(settings): sync name on profile refetch * fix(home): restore cleanupRef assignment dropped during AbortController refactor
1 parent b84f30e commit 8a4c161

File tree

36 files changed

+454
-391
lines changed

36 files changed

+454
-391
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,13 @@ function TextEditor({
215215
onSaveStatusChange?.(saveStatus)
216216
}, [saveStatus, onSaveStatusChange])
217217

218-
useEffect(() => {
219-
if (saveRef) {
220-
saveRef.current = saveImmediately
221-
}
222-
return () => {
223-
if (saveRef) {
224-
saveRef.current = null
225-
}
226-
}
227-
}, [saveRef, saveImmediately])
218+
if (saveRef) saveRef.current = saveImmediately
219+
useEffect(
220+
() => () => {
221+
if (saveRef) saveRef.current = null
222+
},
223+
[saveRef]
224+
)
228225

229226
useEffect(() => {
230227
if (!isResizing) return

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
160160
])
161161

162162
const handleOpenWorkflow = useCallback(() => {
163-
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
164-
}, [router, workspaceId, workflowId])
163+
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
164+
}, [workspaceId, workflowId])
165165

166166
return (
167167
<>
Lines changed: 71 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useCallback, useEffect, useState } from 'react'
3+
import { forwardRef, memo, useCallback, useState } from 'react'
44
import { cn } from '@/lib/core/utils/cn'
55
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
66
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -31,68 +31,79 @@ interface MothershipViewProps {
3131
className?: string
3232
}
3333

34-
export const MothershipView = memo(function MothershipView({
35-
workspaceId,
36-
chatId,
37-
resources,
38-
activeResourceId,
39-
onSelectResource,
40-
onAddResource,
41-
onRemoveResource,
42-
onReorderResources,
43-
onCollapse,
44-
isCollapsed,
45-
className,
46-
}: MothershipViewProps) {
47-
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
34+
export const MothershipView = memo(
35+
forwardRef<HTMLDivElement, MothershipViewProps>(function MothershipView(
36+
{
37+
workspaceId,
38+
chatId,
39+
resources,
40+
activeResourceId,
41+
onSelectResource,
42+
onAddResource,
43+
onRemoveResource,
44+
onReorderResources,
45+
onCollapse,
46+
isCollapsed,
47+
className,
48+
}: MothershipViewProps,
49+
ref
50+
) {
51+
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
4852

49-
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
50-
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
53+
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
54+
const [prevActiveId, setPrevActiveId] = useState<string | null | undefined>(active?.id)
55+
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
5156

52-
useEffect(() => {
53-
setPreviewMode('preview')
54-
}, [active?.id])
57+
// Reset preview mode to default when the active resource changes (guarded render-phase update)
58+
if (active?.id !== prevActiveId) {
59+
setPrevActiveId(active?.id)
60+
setPreviewMode('preview')
61+
}
5562

56-
const isActivePreviewable =
57-
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
63+
const isActivePreviewable =
64+
active?.type === 'file' && RICH_PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
5865

59-
return (
60-
<div
61-
className={cn(
62-
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
63-
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
64-
className
65-
)}
66-
>
67-
<div className='flex min-h-0 flex-1 flex-col'>
68-
<ResourceTabs
69-
workspaceId={workspaceId}
70-
chatId={chatId}
71-
resources={resources}
72-
activeId={active?.id ?? null}
73-
onSelect={onSelectResource}
74-
onAddResource={onAddResource}
75-
onRemoveResource={onRemoveResource}
76-
onReorderResources={onReorderResources}
77-
onCollapse={onCollapse}
78-
actions={active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null}
79-
previewMode={isActivePreviewable ? previewMode : undefined}
80-
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
81-
/>
82-
<div className='min-h-0 flex-1 overflow-hidden'>
83-
{active ? (
84-
<ResourceContent
85-
workspaceId={workspaceId}
86-
resource={active}
87-
previewMode={isActivePreviewable ? previewMode : undefined}
88-
/>
89-
) : (
90-
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
91-
Click "+" above to add a resource
92-
</div>
93-
)}
66+
return (
67+
<div
68+
ref={ref}
69+
className={cn(
70+
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-300 ease-out',
71+
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-[60%] border-l',
72+
className
73+
)}
74+
>
75+
<div className='flex min-h-0 flex-1 flex-col'>
76+
<ResourceTabs
77+
workspaceId={workspaceId}
78+
chatId={chatId}
79+
resources={resources}
80+
activeId={active?.id ?? null}
81+
onSelect={onSelectResource}
82+
onAddResource={onAddResource}
83+
onRemoveResource={onRemoveResource}
84+
onReorderResources={onReorderResources}
85+
onCollapse={onCollapse}
86+
actions={
87+
active ? <ResourceActions workspaceId={workspaceId} resource={active} /> : null
88+
}
89+
previewMode={isActivePreviewable ? previewMode : undefined}
90+
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
91+
/>
92+
<div className='min-h-0 flex-1 overflow-hidden'>
93+
{active ? (
94+
<ResourceContent
95+
workspaceId={workspaceId}
96+
resource={active}
97+
previewMode={isActivePreviewable ? previewMode : undefined}
98+
/>
99+
) : (
100+
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
101+
Click "+" above to add a resource
102+
</div>
103+
)}
104+
</div>
94105
</div>
95106
</div>
96-
</div>
97-
)
98-
})
107+
)
108+
})
109+
)

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,7 @@ export function UserInput({
202202
}
203203

204204
useEffect(() => {
205-
if (editValue) {
206-
onEditValueConsumed?.()
207-
}
205+
if (editValue) onEditValueConsumed?.()
208206
}, [editValue, onEditValueConsumed])
209207

210208
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)

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

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
UserMessageContent,
2727
} from './components'
2828
import { PendingTagIndicator } from './components/message-content/components/special-tags'
29-
import { useAutoScroll, useChat } from './hooks'
29+
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
3030
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
3131

3232
const logger = createLogger('Home')
@@ -138,26 +138,41 @@ export function Home({ chatId }: HomeProps = {}) {
138138
useChatHistory(chatId)
139139
const { mutate: markRead } = useMarkTaskRead(workspaceId)
140140

141+
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
142+
141143
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
142144
const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false)
143145
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
144146
const isResourceCollapsedRef = useRef(isResourceCollapsed)
145147
isResourceCollapsedRef.current = isResourceCollapsed
146148

147-
const collapseResource = useCallback(() => setIsResourceCollapsed(true), [])
148-
const expandResource = useCallback(() => {
149-
setIsResourceCollapsed(false)
149+
const collapseResource = useCallback(() => {
150+
clearWidth()
151+
setIsResourceCollapsed(true)
152+
}, [clearWidth])
153+
const animatingInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
154+
const startAnimatingIn = useCallback(() => {
155+
if (animatingInTimerRef.current) clearTimeout(animatingInTimerRef.current)
150156
setIsResourceAnimatingIn(true)
157+
animatingInTimerRef.current = setTimeout(() => {
158+
setIsResourceAnimatingIn(false)
159+
animatingInTimerRef.current = null
160+
}, 400)
151161
}, [])
152162

163+
const expandResource = useCallback(() => {
164+
setIsResourceCollapsed(false)
165+
startAnimatingIn()
166+
}, [startAnimatingIn])
167+
153168
const handleResourceEvent = useCallback(() => {
154169
if (isResourceCollapsedRef.current) {
155170
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
156171
if (!isCollapsed) toggleCollapsed()
157172
setIsResourceCollapsed(false)
158-
setIsResourceAnimatingIn(true)
173+
startAnimatingIn()
159174
}
160-
}, [])
175+
}, [startAnimatingIn])
161176

162177
const {
163178
messages,
@@ -178,8 +193,15 @@ export function Home({ chatId }: HomeProps = {}) {
178193
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
179194

180195
const [editingInputValue, setEditingInputValue] = useState('')
196+
const [prevChatId, setPrevChatId] = useState(chatId)
181197
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
182198

199+
// Clear editing value when navigating to a different chat (guarded render-phase update)
200+
if (chatId !== prevChatId) {
201+
setPrevChatId(chatId)
202+
setEditingInputValue('')
203+
}
204+
183205
const handleEditQueuedMessage = useCallback(
184206
(id: string) => {
185207
const msg = editQueuedMessage(id)
@@ -190,10 +212,6 @@ export function Home({ chatId }: HomeProps = {}) {
190212
[editQueuedMessage]
191213
)
192214

193-
useEffect(() => {
194-
setEditingInputValue('')
195-
}, [chatId])
196-
197215
useEffect(() => {
198216
wasSendingRef.current = false
199217
if (resolvedChatId) markRead(resolvedChatId)
@@ -207,23 +225,12 @@ export function Home({ chatId }: HomeProps = {}) {
207225
}, [isSending, resolvedChatId, markRead])
208226

209227
useEffect(() => {
210-
if (!isResourceAnimatingIn) return
211-
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
212-
return () => clearTimeout(timer)
213-
}, [isResourceAnimatingIn])
214-
215-
useEffect(() => {
216-
if (resources.length > 0 && isResourceCollapsedRef.current) {
217-
setSkipResourceTransition(true)
218-
setIsResourceCollapsed(false)
219-
}
220-
}, [resources])
221-
222-
useEffect(() => {
223-
if (!skipResourceTransition) return
228+
if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
229+
setIsResourceCollapsed(false)
230+
setSkipResourceTransition(true)
224231
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
225232
return () => cancelAnimationFrame(id)
226-
}, [skipResourceTransition])
233+
}, [resources])
227234

228235
const handleSubmit = useCallback(
229236
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -359,7 +366,7 @@ export function Home({ chatId }: HomeProps = {}) {
359366

360367
return (
361368
<div className='relative flex h-full bg-[var(--bg)]'>
362-
<div className='flex h-full min-w-0 flex-1 flex-col'>
369+
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
363370
<div
364371
ref={scrollContainerRef}
365372
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
@@ -458,7 +465,21 @@ export function Home({ chatId }: HomeProps = {}) {
458465
</div>
459466
</div>
460467

468+
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
469+
{!isResourceCollapsed && (
470+
<div className='relative z-20 w-0 flex-none'>
471+
<div
472+
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
473+
role='separator'
474+
aria-orientation='vertical'
475+
aria-label='Resize resource panel'
476+
onPointerDown={handleResizePointerDown}
477+
/>
478+
</div>
479+
)}
480+
461481
<MothershipView
482+
ref={mothershipRef}
462483
workspaceId={workspaceId}
463484
chatId={resolvedChatId}
464485
resources={resources}

apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { useAnimatedPlaceholder } from './use-animated-placeholder'
22
export { useAutoScroll } from './use-auto-scroll'
33
export type { UseChatReturn } from './use-chat'
44
export { useChat } from './use-chat'
5+
export { useMothershipResize } from './use-mothership-resize'
56
export { useStreamingReveal } from './use-streaming-reveal'

0 commit comments

Comments
 (0)