Skip to content

Commit 30708ae

Browse files
committed
fix(ui): preserve resource view collapse state
1 parent 5112b07 commit 30708ae

6 files changed

Lines changed: 208 additions & 49 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
1010
/** Shared geometry for the resource header and controls positioned over it. */
1111
export const RESOURCE_HEADER_CLASSES = {
1212
layout:
13-
'[--resource-header-controls-height:40px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
13+
'[--resource-header-controls-height:40px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:64px] [--resource-header-toggle-hit-size:40px] [--resource-header-toggle-size:30px]',
1414
/**
1515
* Drives the tab strip from this header's own tokens rather than restating the
1616
* strip's defaults, so the height the overlaid controls below are positioned
@@ -38,11 +38,10 @@ export const RESOURCE_HEADER_CLASSES = {
3838
overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-center',
3939
endPosition: 'right-[var(--resource-header-end-inset)]',
4040
/**
41-
* Sits a control 1px clear of the overlaid 30px collapse toggle — the same
42-
* chip-to-chip gap the sidebar header cluster uses (`gap-[1px]`), so the
43-
* credits chip and the toggle read as one cluster across both surfaces.
41+
* Clears the collapse toggle's 40px hit target so adjacent controls never
42+
* compete for the same pointer area. The visible toggle remains 30px.
4443
*/
4544
adjacentEndPosition:
46-
'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-size)_+_1px)]',
45+
'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-hit-size)_+_1px)]',
4746
emptyAddOffset: '-translate-x-1.5',
4847
} as const

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

Lines changed: 66 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { captureEvent } from '@/lib/posthog/client'
3636
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
3737
import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
3838
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
39+
import { resolveResourceEventPresentation } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
3940
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
4041
import { useFolders } from '@/hooks/queries/folders'
4142
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
@@ -202,23 +203,32 @@ export function Home({ chatId, userName, userId }: HomeProps) {
202203

203204
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
204205

205-
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
206+
const [isResourceCollapsed, setIsResourceCollapsedState] = useState(true)
206207
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
207208
const [resourceActivityIds, setResourceActivityIds] = useState<Set<string>>(new Set())
208209
const isResourceCollapsedRef = useRef(isResourceCollapsed)
209-
isResourceCollapsedRef.current = isResourceCollapsed
210-
const userOwnsResourceViewRef = useRef(false)
210+
const setResourceCollapsed = useCallback((collapsed: boolean) => {
211+
isResourceCollapsedRef.current = collapsed
212+
setIsResourceCollapsedState(collapsed)
213+
}, [])
214+
const resourceCollapseOwnedByUserRef = useRef(false)
215+
const resourceSelectionOwnedByUserRef = useRef(false)
211216
const activeResourceParamRef = useRef(activeResourceParam)
212217
activeResourceParamRef.current = activeResourceParam
213218

214219
function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) {
215-
// Agent work surfaces the resource and switches to it as it is created or
216-
// edited; only the browser session stays in the background behind an
217-
// existing selection (see shouldActivateResourceEvent).
218-
if (isResourceCollapsedRef.current) setIsResourceCollapsed(false)
219-
220220
const activeResourceId = activeResourceParamRef.current
221-
if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) {
221+
const presentation = resolveResourceEventPresentation({
222+
activeResourceId,
223+
activationRequested: shouldActivateResourceEvent(activeResourceId, resourceId, options),
224+
panelCollapseOwnedByUser: resourceCollapseOwnedByUserRef.current,
225+
panelCollapsed: isResourceCollapsedRef.current,
226+
resourceId,
227+
selectionOwnedByUser: resourceSelectionOwnedByUserRef.current,
228+
})
229+
230+
if (presentation.revealPanel) setResourceCollapsed(false)
231+
if (presentation.markActivity) {
222232
setResourceActivityIds((current) => new Set(current).add(resourceId))
223233
return
224234
}
@@ -228,7 +238,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
228238
next.delete(resourceId)
229239
return next
230240
})
231-
if (activeResourceId !== resourceId) {
241+
if (presentation.activateResource && activeResourceId !== resourceId) {
232242
activeResourceParamRef.current = resourceId
233243
setActiveResourceUrl(resourceId)
234244
}
@@ -282,10 +292,11 @@ export function Home({ chatId, userName, userId }: HomeProps) {
282292
const resourceAttentionChatIdRef = useRef(resolvedChatId)
283293

284294
const collapseResource = useCallback(() => {
285-
userOwnsResourceViewRef.current = true
295+
resourceCollapseOwnedByUserRef.current = true
296+
resourceSelectionOwnedByUserRef.current = true
286297
clearWidth()
287-
setIsResourceCollapsed(true)
288-
}, [clearWidth])
298+
setResourceCollapsed(true)
299+
}, [clearWidth, setResourceCollapsed])
289300

290301
const clearResourceActivity = useCallback((resourceId: string) => {
291302
setResourceActivityIds((current) => {
@@ -297,15 +308,16 @@ export function Home({ chatId, userName, userId }: HomeProps) {
297308
}, [])
298309

299310
const expandResource = () => {
300-
userOwnsResourceViewRef.current = true
311+
resourceCollapseOwnedByUserRef.current = false
312+
resourceSelectionOwnedByUserRef.current = true
301313
const activeResourceId = activeResourceParamRef.current
302314
if (activeResourceId) clearResourceActivity(activeResourceId)
303-
setIsResourceCollapsed(false)
315+
setResourceCollapsed(false)
304316
}
305317

306318
const selectResourceFromUser = useCallback(
307319
(resourceId: string) => {
308-
userOwnsResourceViewRef.current = true
320+
resourceSelectionOwnedByUserRef.current = true
309321
clearResourceActivity(resourceId)
310322
if (effectiveActiveResourceIdRef.current === resourceId) return
311323
effectiveActiveResourceIdRef.current = resourceId
@@ -317,24 +329,30 @@ export function Home({ chatId, userName, userId }: HomeProps) {
317329

318330
const addResourceFromUser = useCallback(
319331
(resource: MothershipResource) => {
320-
userOwnsResourceViewRef.current = true
332+
resourceCollapseOwnedByUserRef.current = false
333+
resourceSelectionOwnedByUserRef.current = true
321334
addResource(resource)
322335
selectResourceFromUser(resource.id)
323-
setIsResourceCollapsed(false)
336+
setResourceCollapsed(false)
324337
},
325-
[addResource, selectResourceFromUser]
338+
[addResource, selectResourceFromUser, setResourceCollapsed]
326339
)
327340

328341
const handleResourceResizePointerDown = useCallback(
329342
(event: PointerEvent<HTMLDivElement>) => {
330-
userOwnsResourceViewRef.current = true
343+
resourceSelectionOwnedByUserRef.current = true
331344
handleResizePointerDown(event)
332345
},
333346
[handleResizePointerDown]
334347
)
335348

336349
const handleResourceInteraction = useCallback(() => {
337-
userOwnsResourceViewRef.current = true
350+
resourceSelectionOwnedByUserRef.current = true
351+
}, [])
352+
353+
const prepareResourceViewForAgentTurn = useCallback(() => {
354+
resourceSelectionOwnedByUserRef.current = false
355+
setResourceActivityIds(new Set())
338356
}, [])
339357

340358
useEffect(() => {
@@ -345,13 +363,14 @@ export function Home({ chatId, userName, userId }: HomeProps) {
345363
markRead(resolvedChatId)
346364
} else {
347365
clearWidth()
348-
setIsResourceCollapsed(true)
366+
setResourceCollapsed(true)
349367
}
350368
if (!resolvedChatId || (previousChatId && previousChatId !== resolvedChatId)) {
351-
userOwnsResourceViewRef.current = false
369+
resourceCollapseOwnedByUserRef.current = false
370+
resourceSelectionOwnedByUserRef.current = false
352371
setResourceActivityIds(new Set())
353372
}
354-
}, [resolvedChatId, markRead, clearWidth])
373+
}, [resolvedChatId, markRead, clearWidth, setResourceCollapsed])
355374

356375
useEffect(() => {
357376
if (wasSendingRef.current && !isSending && resolvedChatId) {
@@ -363,22 +382,22 @@ export function Home({ chatId, userName, userId }: HomeProps) {
363382
useEffect(() => {
364383
if (
365384
!(resources.length > 0 && isResourceCollapsedRef.current) ||
366-
userOwnsResourceViewRef.current
385+
resourceCollapseOwnedByUserRef.current
367386
) {
368387
return
369388
}
370-
setIsResourceCollapsed(false)
389+
setResourceCollapsed(false)
371390
setSkipResourceTransition(true)
372391
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
373392
return () => cancelAnimationFrame(id)
374-
}, [resources])
393+
}, [resources, setResourceCollapsed])
375394

376395
useEffect(() => {
377396
if (resources.length === 0 && !isResourceCollapsedRef.current) {
378397
clearWidth()
379-
setIsResourceCollapsed(true)
398+
setResourceCollapsed(true)
380399
}
381-
}, [resources, clearWidth])
400+
}, [resources, clearWidth, setResourceCollapsed])
382401

383402
useEffect(() => {
384403
const resourceIds = new Set(resources.map((resource) => resource.id))
@@ -413,11 +432,10 @@ export function Home({ chatId, userName, userId }: HomeProps) {
413432
setIsInputEntering(true)
414433
}
415434

416-
userOwnsResourceViewRef.current = false
417-
setResourceActivityIds(new Set())
435+
prepareResourceViewForAgentTurn()
418436
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
419437
},
420-
[workspaceId, chatId, sendMessage]
438+
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage]
421439
)
422440

423441
/**
@@ -431,13 +449,14 @@ export function Home({ chatId, userName, userId }: HomeProps) {
431449
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
432450
if (!detail?.message) return
433451
e.preventDefault()
452+
prepareResourceViewForAgentTurn()
434453
sendMessage(detail.message, detail.fileAttachments, detail.contexts, {
435454
...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}),
436455
})
437456
}
438457
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
439458
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
440-
}, [sendMessage])
459+
}, [prepareResourceViewForAgentTurn, sendMessage])
441460

442461
/**
443462
* Consumes a one-shot handoff left by another surface and applies it to this
@@ -462,6 +481,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
462481
const handoff = MothershipHandoffStorage.consume(workspaceId)
463482
if (!handoff) return
464483
if (handoff.message) {
484+
prepareResourceViewForAgentTurn()
465485
sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, {
466486
...(handoff.resumeUserMessageId
467487
? { resumeUserMessageId: handoff.resumeUserMessageId }
@@ -477,7 +497,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
477497
// keep it one-shot — and harmless either way, since `consume` clears the entry
478498
// atomically and any re-run would find nothing.
479499
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
480-
}, [chatId, workspaceId, sendMessage])
500+
}, [chatId, workspaceId, prepareResourceViewForAgentTurn, sendMessage])
481501

482502
function resolveResourceFromContext(
483503
context: ChatContext
@@ -583,6 +603,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
583603
const hasMessages = messages.length > 0
584604
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
585605
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
606+
const resourceActivityCount = resourceActivityIds.size
607+
const resourceToggleLabel = isResourceCollapsed
608+
? resourceActivityCount > 0
609+
? `Expand resource view, ${resourceActivityCount} resource${resourceActivityCount === 1 ? '' : 's'} updated`
610+
: 'Expand resource view'
611+
: 'Collapse resource view'
586612

587613
// The empty state is the chat pane's content, not a layout of its own. It
588614
// used to return early, which meant the resource panel and its toggle did
@@ -720,13 +746,16 @@ export function Home({ chatId, userName, userId }: HomeProps) {
720746
size={null}
721747
type='button'
722748
onClick={isResourceCollapsed ? expandResource : collapseResource}
723-
className='size-[var(--resource-header-toggle-size)] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
724-
aria-label={isResourceCollapsed ? 'Expand resource view' : 'Collapse resource view'}
749+
className="after:-translate-x-1/2 after:-translate-y-1/2 relative size-[var(--resource-header-toggle-size)] rounded-[8px] after:absolute after:top-1/2 after:left-1/2 after:size-[var(--resource-header-toggle-hit-size)] after:content-[''] hover-hover:bg-[var(--surface-active)]"
750+
aria-label={resourceToggleLabel}
725751
>
726752
<span className='relative'>
727753
<PanelLeft className='-scale-x-100 size-[16px] text-[var(--text-icon)]' />
728754
{isResourceCollapsed && resourceActivityIds.size > 0 && (
729-
<span className='-top-0.5 -right-0.5 absolute size-1.5 rounded-full bg-[var(--brand-primary)]' />
755+
<span
756+
aria-hidden='true'
757+
className='-top-0.5 -right-0.5 absolute size-1.5 rounded-full bg-[var(--brand-primary)]'
758+
/>
730759
)}
731760
</span>
732761
</Button>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ describe('selectDeletedWorkflowResources', () => {
6161
})
6262

6363
describe('shouldActivateResourceEvent', () => {
64-
it('surfaces browser work even when another resource is selected', () => {
64+
it('requests activation for browser work', () => {
6565
expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(true)
6666
})
6767

68-
it('surfaces every other resource the agent touches', () => {
68+
it('requests activation for every other resource the agent touches', () => {
6969
expect(shouldActivateResourceEvent('file-1', 'workflow-1')).toBe(true)
7070
expect(shouldActivateResourceEvent('browser-session', 'terminal-session')).toBe(true)
7171
expect(shouldActivateResourceEvent(null, 'browser-session')).toBe(true)

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,11 +1215,9 @@ export interface ResourceEventOptions {
12151215
export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void
12161216

12171217
/**
1218-
* Whether a streamed resource event should activate its tab. The panel always
1219-
* follows the agent: whatever it is creating, editing, or driving becomes the
1220-
* visible resource, browser sessions included. The parameters are retained so
1221-
* callers stay explicit about the resource in play, and so a future opt-out
1222-
* (an event that deliberately declines focus) has a place to live.
1218+
* Whether a streamed resource event requests activation of its tab. The view
1219+
* may still preserve an explicit user collapse or selection and surface the
1220+
* event through an activity marker instead.
12231221
*/
12241222
export function shouldActivateResourceEvent(
12251223
_activeResourceId: string | null,

0 commit comments

Comments
 (0)