Skip to content

Commit 1413d8a

Browse files
committed
Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot
2 parents 60e326f + f7acc18 commit 1413d8a

File tree

3 files changed

+241
-70
lines changed

3 files changed

+241
-70
lines changed

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,14 @@ export function Home({ chatId }: HomeProps = {}) {
167167
sendMessage,
168168
stopGeneration,
169169
resources,
170+
isResourceCleanupSettled,
170171
activeResourceId,
171172
setActiveResourceId,
172173
} = useChat(workspaceId, chatId)
173174

174175
const [isResourceCollapsed, setIsResourceCollapsed] = useState(false)
175176
const [showExpandButton, setShowExpandButton] = useState(false)
177+
const [isResourceAnimatingIn, setIsResourceAnimatingIn] = useState(false)
176178

177179
useEffect(() => {
178180
if (!isResourceCollapsed) {
@@ -186,16 +188,24 @@ export function Home({ chatId }: HomeProps = {}) {
186188
const collapseResource = useCallback(() => setIsResourceCollapsed(true), [])
187189
const expandResource = useCallback(() => setIsResourceCollapsed(false), [])
188190

189-
const prevResourceCountRef = useRef(resources.length)
190-
const animateResourcePanel =
191-
prevResourceCountRef.current === 0 && resources.length > 0 && isSending
191+
const visibleResources = isResourceCleanupSettled ? resources : []
192+
const prevResourceCountRef = useRef(visibleResources.length)
193+
const shouldEnterResourcePanel =
194+
isSending && prevResourceCountRef.current === 0 && visibleResources.length > 0
192195
useEffect(() => {
193-
if (animateResourcePanel) {
196+
if (shouldEnterResourcePanel) {
194197
const { isCollapsed, toggleCollapsed } = useSidebarStore.getState()
195198
if (!isCollapsed) toggleCollapsed()
199+
setIsResourceAnimatingIn(true)
196200
}
197-
prevResourceCountRef.current = resources.length
198-
})
201+
prevResourceCountRef.current = visibleResources.length
202+
}, [shouldEnterResourcePanel, visibleResources.length])
203+
204+
useEffect(() => {
205+
if (!isResourceAnimatingIn) return
206+
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
207+
return () => clearTimeout(timer)
208+
}, [isResourceAnimatingIn])
199209

200210
const handleSubmit = useCallback(
201211
(text: string, fileAttachments?: FileAttachmentForApi[]) => {
@@ -340,19 +350,19 @@ export function Home({ chatId }: HomeProps = {}) {
340350
</div>
341351
</div>
342352

343-
{resources.length > 0 && (
353+
{visibleResources.length > 0 && (
344354
<MothershipView
345355
workspaceId={workspaceId}
346-
resources={resources}
356+
resources={visibleResources}
347357
activeResourceId={activeResourceId}
348358
onSelectResource={setActiveResourceId}
349359
onCollapse={collapseResource}
350360
isCollapsed={isResourceCollapsed}
351-
className={animateResourcePanel ? 'animate-slide-in-right' : undefined}
361+
className={isResourceAnimatingIn ? 'animate-slide-in-right' : undefined}
352362
/>
353363
)}
354364

355-
{resources.length > 0 && showExpandButton && (
365+
{visibleResources.length > 0 && showExpandButton && (
356366
<div className='absolute top-[8.5px] right-[16px]'>
357367
<button
358368
type='button'

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

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ const BOTTOM_THRESHOLD = 30
66
* Manages sticky auto-scroll for a streaming chat container.
77
*
88
* Stays pinned to the bottom while content streams in. Detaches when the user
9-
* explicitly scrolls up (wheel, touch, or scrollbar drag). Re-attaches when
10-
* the scroll position returns to within {@link BOTTOM_THRESHOLD} of the bottom.
9+
* scrolls beyond {@link BOTTOM_THRESHOLD} from the bottom. Re-attaches when
10+
* the scroll position returns within the threshold. Preserves bottom position
11+
* across container resizes (e.g. sidebar collapse).
1112
*/
1213
export function useAutoScroll(isStreaming: boolean) {
1314
const containerRef = useRef<HTMLDivElement>(null)
14-
const stickyRef = useRef(true)
15-
const prevScrollTopRef = useRef(0)
16-
const prevScrollHeightRef = useRef(0)
17-
const touchStartYRef = useRef(0)
15+
const atBottomRef = useRef(true)
1816
const rafIdRef = useRef(0)
17+
const teardownRef = useRef<(() => void) | null>(null)
1918

2019
const scrollToBottom = useCallback(() => {
2120
const el = containerRef.current
@@ -24,80 +23,57 @@ export function useAutoScroll(isStreaming: boolean) {
2423
}, [])
2524

2625
const callbackRef = useCallback((el: HTMLDivElement | null) => {
26+
teardownRef.current?.()
27+
teardownRef.current = null
2728
containerRef.current = el
28-
if (el) el.scrollTop = el.scrollHeight
29-
}, [])
30-
31-
useEffect(() => {
32-
if (!isStreaming) return
33-
const el = containerRef.current
3429
if (!el) return
3530

36-
stickyRef.current = true
37-
prevScrollTopRef.current = el.scrollTop
38-
prevScrollHeightRef.current = el.scrollHeight
39-
scrollToBottom()
31+
el.scrollTop = el.scrollHeight
32+
atBottomRef.current = true
4033

41-
const detach = () => {
42-
stickyRef.current = false
34+
const onScroll = () => {
35+
const { scrollTop, scrollHeight, clientHeight } = el
36+
atBottomRef.current = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
4337
}
4438

45-
const onWheel = (e: WheelEvent) => {
46-
if (e.deltaY < 0) detach()
47-
}
39+
const ro = new ResizeObserver(() => {
40+
if (atBottomRef.current) el.scrollTop = el.scrollHeight
41+
})
4842

49-
const onTouchStart = (e: TouchEvent) => {
50-
touchStartYRef.current = e.touches[0].clientY
51-
}
43+
el.addEventListener('scroll', onScroll, { passive: true })
44+
ro.observe(el)
5245

53-
const onTouchMove = (e: TouchEvent) => {
54-
if (e.touches[0].clientY > touchStartYRef.current) detach()
46+
teardownRef.current = () => {
47+
el.removeEventListener('scroll', onScroll)
48+
ro.disconnect()
5549
}
50+
}, [])
5651

57-
const onScroll = () => {
58-
const { scrollTop, scrollHeight, clientHeight } = el
59-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
60-
61-
if (distanceFromBottom <= BOTTOM_THRESHOLD) {
62-
stickyRef.current = true
63-
} else if (
64-
scrollTop < prevScrollTopRef.current &&
65-
scrollHeight <= prevScrollHeightRef.current
66-
) {
67-
stickyRef.current = false
68-
}
69-
70-
prevScrollTopRef.current = scrollTop
71-
prevScrollHeightRef.current = scrollHeight
72-
}
52+
useEffect(() => {
53+
if (!isStreaming) return
54+
const el = containerRef.current
55+
if (!el) return
56+
57+
atBottomRef.current = true
58+
scrollToBottom()
7359

7460
const guardedScroll = () => {
75-
if (stickyRef.current) scrollToBottom()
61+
if (atBottomRef.current) scrollToBottom()
7662
}
7763

7864
const onMutation = () => {
79-
prevScrollHeightRef.current = el.scrollHeight
80-
if (!stickyRef.current) return
65+
if (!atBottomRef.current) return
8166
cancelAnimationFrame(rafIdRef.current)
8267
rafIdRef.current = requestAnimationFrame(guardedScroll)
8368
}
8469

85-
el.addEventListener('wheel', onWheel, { passive: true })
86-
el.addEventListener('touchstart', onTouchStart, { passive: true })
87-
el.addEventListener('touchmove', onTouchMove, { passive: true })
88-
el.addEventListener('scroll', onScroll, { passive: true })
89-
9070
const observer = new MutationObserver(onMutation)
9171
observer.observe(el, { childList: true, subtree: true, characterData: true })
9272

9373
return () => {
94-
el.removeEventListener('wheel', onWheel)
95-
el.removeEventListener('touchstart', onTouchStart)
96-
el.removeEventListener('touchmove', onTouchMove)
97-
el.removeEventListener('scroll', onScroll)
9874
observer.disconnect()
9975
cancelAnimationFrame(rafIdRef.current)
100-
if (stickyRef.current) scrollToBottom()
76+
if (atBottomRef.current) scrollToBottom()
10177
}
10278
}, [isStreaming, scrollToBottom])
10379

0 commit comments

Comments
 (0)