Skip to content

Commit ba928f6

Browse files
committed
feat(home): resizable chat/resource panel divider
1 parent 28de288 commit ba928f6

File tree

6 files changed

+179
-78
lines changed

6 files changed

+179
-78
lines changed

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/home.tsx

Lines changed: 33 additions & 16 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,13 +138,18 @@ export function Home({ chatId }: HomeProps = {}) {
138138
useChatHistory(chatId)
139139
const { mutate: markRead } = useMarkTaskRead(workspaceId)
140140

141+
const { mothershipRef, handleResizeMouseDown, 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), [])
149+
const collapseResource = useCallback(() => {
150+
clearWidth()
151+
setIsResourceCollapsed(true)
152+
}, [clearWidth])
148153
const expandResource = useCallback(() => {
149154
setIsResourceCollapsed(false)
150155
setIsResourceAnimatingIn(true)
@@ -178,8 +183,15 @@ export function Home({ chatId }: HomeProps = {}) {
178183
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
179184

180185
const [editingInputValue, setEditingInputValue] = useState('')
186+
const [prevChatId, setPrevChatId] = useState(chatId)
181187
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
182188

189+
// Clear editing value when navigating to a different chat (guarded render-phase update)
190+
if (chatId !== prevChatId) {
191+
setPrevChatId(chatId)
192+
setEditingInputValue('')
193+
}
194+
183195
const handleEditQueuedMessage = useCallback(
184196
(id: string) => {
185197
const msg = editQueuedMessage(id)
@@ -190,10 +202,6 @@ export function Home({ chatId }: HomeProps = {}) {
190202
[editQueuedMessage]
191203
)
192204

193-
useEffect(() => {
194-
setEditingInputValue('')
195-
}, [chatId])
196-
197205
useEffect(() => {
198206
wasSendingRef.current = false
199207
if (resolvedChatId) markRead(resolvedChatId)
@@ -213,17 +221,12 @@ export function Home({ chatId }: HomeProps = {}) {
213221
}, [isResourceAnimatingIn])
214222

215223
useEffect(() => {
216-
if (resources.length > 0 && isResourceCollapsedRef.current) {
217-
setSkipResourceTransition(true)
218-
setIsResourceCollapsed(false)
219-
}
220-
}, [resources])
221-
222-
useEffect(() => {
223-
if (!skipResourceTransition) return
224+
if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
225+
setIsResourceCollapsed(false)
226+
setSkipResourceTransition(true)
224227
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
225228
return () => cancelAnimationFrame(id)
226-
}, [skipResourceTransition])
229+
}, [resources])
227230

228231
const handleSubmit = useCallback(
229232
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
@@ -359,7 +362,7 @@ export function Home({ chatId }: HomeProps = {}) {
359362

360363
return (
361364
<div className='relative flex h-full bg-[var(--bg)]'>
362-
<div className='flex h-full min-w-0 flex-1 flex-col'>
365+
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
363366
<div
364367
ref={scrollContainerRef}
365368
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
@@ -458,7 +461,21 @@ export function Home({ chatId }: HomeProps = {}) {
458461
</div>
459462
</div>
460463

464+
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
465+
{!isResourceCollapsed && (
466+
<div className='relative z-20 w-0 flex-none' aria-hidden='true'>
467+
<div
468+
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
469+
role='separator'
470+
aria-orientation='vertical'
471+
aria-label='Resize resource panel'
472+
onMouseDown={handleResizeMouseDown}
473+
/>
474+
</div>
475+
)}
476+
461477
<MothershipView
478+
ref={mothershipRef}
462479
workspaceId={workspaceId}
463480
chatId={resolvedChatId}
464481
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'
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useCallback, useEffect, useRef } from 'react'
2+
import { MOTHERSHIP_WIDTH } from '@/stores/constants'
3+
4+
/**
5+
* Hook for managing resize of the MothershipView resource panel.
6+
*
7+
* Uses imperative DOM manipulation (zero React re-renders during drag).
8+
* Attach `mothershipRef` to the MothershipView root div and call
9+
* `handleResizeMouseDown` from the drag handle's onMouseDown.
10+
* Call `clearWidth` when the panel collapses so the CSS class retakes control.
11+
*/
12+
export function useMothershipResize() {
13+
const mothershipRef = useRef<HTMLDivElement | null>(null)
14+
// Stored so the useEffect cleanup can tear down listeners if the component unmounts mid-drag
15+
const cleanupRef = useRef<(() => void) | null>(null)
16+
17+
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
18+
e.preventDefault()
19+
20+
const el = mothershipRef.current
21+
if (!el) return
22+
23+
// Pin to current rendered width so drag starts from the visual position
24+
el.style.width = `${el.getBoundingClientRect().width}px`
25+
26+
// Disable CSS transition to prevent animation lag during drag
27+
const prevTransition = el.style.transition
28+
el.style.transition = 'none'
29+
document.body.style.cursor = 'ew-resize'
30+
document.body.style.userSelect = 'none'
31+
32+
const handleMouseMove = (moveEvent: MouseEvent) => {
33+
const newWidth = window.innerWidth - moveEvent.clientX
34+
const maxWidth = window.innerWidth * MOTHERSHIP_WIDTH.MAX_PERCENTAGE
35+
el.style.width = `${Math.min(Math.max(newWidth, MOTHERSHIP_WIDTH.MIN), maxWidth)}px`
36+
}
37+
38+
const handleMouseUp = () => {
39+
el.style.transition = prevTransition
40+
document.body.style.cursor = ''
41+
document.body.style.userSelect = ''
42+
document.removeEventListener('mousemove', handleMouseMove)
43+
document.removeEventListener('mouseup', handleMouseUp)
44+
cleanupRef.current = null
45+
}
46+
47+
cleanupRef.current = handleMouseUp
48+
document.addEventListener('mousemove', handleMouseMove)
49+
document.addEventListener('mouseup', handleMouseUp)
50+
}, [])
51+
52+
// Tear down any active drag if the component unmounts mid-drag
53+
useEffect(() => {
54+
return () => {
55+
cleanupRef.current?.()
56+
}
57+
}, [])
58+
59+
/** Remove inline width so the collapse CSS class retakes control */
60+
const clearWidth = useCallback(() => {
61+
mothershipRef.current?.style.removeProperty('width')
62+
}, [])
63+
64+
return { mothershipRef, handleResizeMouseDown, clearWidth }
65+
}

apps/sim/stores/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,12 @@ export const OUTPUT_PANEL_WIDTH = {
6464
MIN: 280,
6565
} as const
6666

67+
/** Home chat resource panel (MothershipView) width constraints */
68+
export const MOTHERSHIP_WIDTH = {
69+
MIN: 280,
70+
/** Maximum is 70% of viewport, enforced dynamically */
71+
MAX_PERCENTAGE: 0.7,
72+
} as const
73+
6774
/** Terminal block column width - minimum width for the logs column */
6875
export const TERMINAL_BLOCK_COLUMN_WIDTH = 240 as const

0 commit comments

Comments
 (0)