Skip to content

Commit 76c7be5

Browse files
authored
fix(ui): preserve resource view collapse state (#7147)
* fix(ui): preserve resource view collapse state * fix(ui): follow resources after selection removal * fix(ui): synchronize resource selection updates
1 parent 5112b07 commit 76c7be5

6 files changed

Lines changed: 247 additions & 52 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: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ 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 {
40+
resolveResourceEventPresentation,
41+
resolveResourceSelectionUpdate,
42+
} from '@/app/workspace/[workspaceId]/home/resource-view-policy'
3943
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
4044
import { useFolders } from '@/hooks/queries/folders'
4145
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
@@ -102,6 +106,8 @@ export function Home({ chatId, userName, userId }: HomeProps) {
102106
...resourceParam.parser,
103107
...resourceUrlKeys,
104108
})
109+
const activeResourceParamRef = useRef(activeResourceParam)
110+
activeResourceParamRef.current = activeResourceParam
105111
/**
106112
* Strips any leftover URL fragment on selection change, preserving the old
107113
* effect's `url.hash = ''` (the only hash usage on this surface) without a
@@ -114,11 +120,13 @@ export function Home({ chatId, userName, userId }: HomeProps) {
114120
*/
115121
const setActiveResourceUrl = useCallback<Dispatch<SetStateAction<string | null>>>(
116122
(action) => {
123+
const nextResourceId = resolveResourceSelectionUpdate(activeResourceParamRef.current, action)
124+
activeResourceParamRef.current = nextResourceId
117125
if (typeof window !== 'undefined' && window.location.hash) {
118126
const { pathname, search } = window.location
119127
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
120128
}
121-
void setResourceParam(action)
129+
void setResourceParam(nextResourceId)
122130
},
123131
[setResourceParam]
124132
)
@@ -202,23 +210,30 @@ export function Home({ chatId, userName, userId }: HomeProps) {
202210

203211
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
204212

205-
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
213+
const [isResourceCollapsed, setIsResourceCollapsedState] = useState(true)
206214
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
207215
const [resourceActivityIds, setResourceActivityIds] = useState<Set<string>>(new Set())
208216
const isResourceCollapsedRef = useRef(isResourceCollapsed)
209-
isResourceCollapsedRef.current = isResourceCollapsed
210-
const userOwnsResourceViewRef = useRef(false)
211-
const activeResourceParamRef = useRef(activeResourceParam)
212-
activeResourceParamRef.current = activeResourceParam
217+
const setResourceCollapsed = useCallback((collapsed: boolean) => {
218+
isResourceCollapsedRef.current = collapsed
219+
setIsResourceCollapsedState(collapsed)
220+
}, [])
221+
const resourceCollapseOwnedByUserRef = useRef(false)
222+
const resourceSelectionOwnedByUserRef = useRef(false)
213223

214224
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-
220225
const activeResourceId = activeResourceParamRef.current
221-
if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) {
226+
const presentation = resolveResourceEventPresentation({
227+
activeResourceId,
228+
activationRequested: shouldActivateResourceEvent(activeResourceId, resourceId, options),
229+
panelCollapseOwnedByUser: resourceCollapseOwnedByUserRef.current,
230+
panelCollapsed: isResourceCollapsedRef.current,
231+
resourceId,
232+
selectionOwnedByUser: resourceSelectionOwnedByUserRef.current,
233+
})
234+
235+
if (presentation.revealPanel) setResourceCollapsed(false)
236+
if (presentation.markActivity) {
222237
setResourceActivityIds((current) => new Set(current).add(resourceId))
223238
return
224239
}
@@ -228,7 +243,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
228243
next.delete(resourceId)
229244
return next
230245
})
231-
if (activeResourceId !== resourceId) {
246+
if (presentation.activateResource && activeResourceId !== resourceId) {
232247
activeResourceParamRef.current = resourceId
233248
setActiveResourceUrl(resourceId)
234249
}
@@ -282,10 +297,11 @@ export function Home({ chatId, userName, userId }: HomeProps) {
282297
const resourceAttentionChatIdRef = useRef(resolvedChatId)
283298

284299
const collapseResource = useCallback(() => {
285-
userOwnsResourceViewRef.current = true
300+
resourceCollapseOwnedByUserRef.current = true
301+
resourceSelectionOwnedByUserRef.current = true
286302
clearWidth()
287-
setIsResourceCollapsed(true)
288-
}, [clearWidth])
303+
setResourceCollapsed(true)
304+
}, [clearWidth, setResourceCollapsed])
289305

290306
const clearResourceActivity = useCallback((resourceId: string) => {
291307
setResourceActivityIds((current) => {
@@ -297,15 +313,16 @@ export function Home({ chatId, userName, userId }: HomeProps) {
297313
}, [])
298314

299315
const expandResource = () => {
300-
userOwnsResourceViewRef.current = true
316+
resourceCollapseOwnedByUserRef.current = false
317+
resourceSelectionOwnedByUserRef.current = true
301318
const activeResourceId = activeResourceParamRef.current
302319
if (activeResourceId) clearResourceActivity(activeResourceId)
303-
setIsResourceCollapsed(false)
320+
setResourceCollapsed(false)
304321
}
305322

306323
const selectResourceFromUser = useCallback(
307324
(resourceId: string) => {
308-
userOwnsResourceViewRef.current = true
325+
resourceSelectionOwnedByUserRef.current = true
309326
clearResourceActivity(resourceId)
310327
if (effectiveActiveResourceIdRef.current === resourceId) return
311328
effectiveActiveResourceIdRef.current = resourceId
@@ -317,24 +334,30 @@ export function Home({ chatId, userName, userId }: HomeProps) {
317334

318335
const addResourceFromUser = useCallback(
319336
(resource: MothershipResource) => {
320-
userOwnsResourceViewRef.current = true
337+
resourceCollapseOwnedByUserRef.current = false
338+
resourceSelectionOwnedByUserRef.current = true
321339
addResource(resource)
322340
selectResourceFromUser(resource.id)
323-
setIsResourceCollapsed(false)
341+
setResourceCollapsed(false)
324342
},
325-
[addResource, selectResourceFromUser]
343+
[addResource, selectResourceFromUser, setResourceCollapsed]
326344
)
327345

328346
const handleResourceResizePointerDown = useCallback(
329347
(event: PointerEvent<HTMLDivElement>) => {
330-
userOwnsResourceViewRef.current = true
348+
resourceSelectionOwnedByUserRef.current = true
331349
handleResizePointerDown(event)
332350
},
333351
[handleResizePointerDown]
334352
)
335353

336354
const handleResourceInteraction = useCallback(() => {
337-
userOwnsResourceViewRef.current = true
355+
resourceSelectionOwnedByUserRef.current = true
356+
}, [])
357+
358+
const prepareResourceViewForAgentTurn = useCallback(() => {
359+
resourceSelectionOwnedByUserRef.current = false
360+
setResourceActivityIds(new Set())
338361
}, [])
339362

340363
useEffect(() => {
@@ -345,13 +368,14 @@ export function Home({ chatId, userName, userId }: HomeProps) {
345368
markRead(resolvedChatId)
346369
} else {
347370
clearWidth()
348-
setIsResourceCollapsed(true)
371+
setResourceCollapsed(true)
349372
}
350373
if (!resolvedChatId || (previousChatId && previousChatId !== resolvedChatId)) {
351-
userOwnsResourceViewRef.current = false
374+
resourceCollapseOwnedByUserRef.current = false
375+
resourceSelectionOwnedByUserRef.current = false
352376
setResourceActivityIds(new Set())
353377
}
354-
}, [resolvedChatId, markRead, clearWidth])
378+
}, [resolvedChatId, markRead, clearWidth, setResourceCollapsed])
355379

356380
useEffect(() => {
357381
if (wasSendingRef.current && !isSending && resolvedChatId) {
@@ -363,22 +387,22 @@ export function Home({ chatId, userName, userId }: HomeProps) {
363387
useEffect(() => {
364388
if (
365389
!(resources.length > 0 && isResourceCollapsedRef.current) ||
366-
userOwnsResourceViewRef.current
390+
resourceCollapseOwnedByUserRef.current
367391
) {
368392
return
369393
}
370-
setIsResourceCollapsed(false)
394+
setResourceCollapsed(false)
371395
setSkipResourceTransition(true)
372396
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
373397
return () => cancelAnimationFrame(id)
374-
}, [resources])
398+
}, [resources, setResourceCollapsed])
375399

376400
useEffect(() => {
377401
if (resources.length === 0 && !isResourceCollapsedRef.current) {
378402
clearWidth()
379-
setIsResourceCollapsed(true)
403+
setResourceCollapsed(true)
380404
}
381-
}, [resources, clearWidth])
405+
}, [resources, clearWidth, setResourceCollapsed])
382406

383407
useEffect(() => {
384408
const resourceIds = new Set(resources.map((resource) => resource.id))
@@ -413,11 +437,10 @@ export function Home({ chatId, userName, userId }: HomeProps) {
413437
setIsInputEntering(true)
414438
}
415439

416-
userOwnsResourceViewRef.current = false
417-
setResourceActivityIds(new Set())
440+
prepareResourceViewForAgentTurn()
418441
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
419442
},
420-
[workspaceId, chatId, sendMessage]
443+
[workspaceId, chatId, prepareResourceViewForAgentTurn, sendMessage]
421444
)
422445

423446
/**
@@ -431,13 +454,14 @@ export function Home({ chatId, userName, userId }: HomeProps) {
431454
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
432455
if (!detail?.message) return
433456
e.preventDefault()
457+
prepareResourceViewForAgentTurn()
434458
sendMessage(detail.message, detail.fileAttachments, detail.contexts, {
435459
...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}),
436460
})
437461
}
438462
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
439463
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
440-
}, [sendMessage])
464+
}, [prepareResourceViewForAgentTurn, sendMessage])
441465

442466
/**
443467
* Consumes a one-shot handoff left by another surface and applies it to this
@@ -462,6 +486,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
462486
const handoff = MothershipHandoffStorage.consume(workspaceId)
463487
if (!handoff) return
464488
if (handoff.message) {
489+
prepareResourceViewForAgentTurn()
465490
sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts, {
466491
...(handoff.resumeUserMessageId
467492
? { resumeUserMessageId: handoff.resumeUserMessageId }
@@ -477,7 +502,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
477502
// keep it one-shot — and harmless either way, since `consume` clears the entry
478503
// atomically and any re-run would find nothing.
479504
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
480-
}, [chatId, workspaceId, sendMessage])
505+
}, [chatId, workspaceId, prepareResourceViewForAgentTurn, sendMessage])
481506

482507
function resolveResourceFromContext(
483508
context: ChatContext
@@ -583,6 +608,12 @@ export function Home({ chatId, userName, userId }: HomeProps) {
583608
const hasMessages = messages.length > 0
584609
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
585610
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
611+
const resourceActivityCount = resourceActivityIds.size
612+
const resourceToggleLabel = isResourceCollapsed
613+
? resourceActivityCount > 0
614+
? `Expand resource view, ${resourceActivityCount} resource${resourceActivityCount === 1 ? '' : 's'} updated`
615+
: 'Expand resource view'
616+
: 'Collapse resource view'
586617

587618
// The empty state is the chat pane's content, not a layout of its own. It
588619
// used to return early, which meant the resource panel and its toggle did
@@ -720,13 +751,16 @@ export function Home({ chatId, userName, userId }: HomeProps) {
720751
size={null}
721752
type='button'
722753
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'}
754+
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)]"
755+
aria-label={resourceToggleLabel}
725756
>
726757
<span className='relative'>
727758
<PanelLeft className='-scale-x-100 size-[16px] text-[var(--text-icon)]' />
728759
{isResourceCollapsed && resourceActivityIds.size > 0 && (
729-
<span className='-top-0.5 -right-0.5 absolute size-1.5 rounded-full bg-[var(--brand-primary)]' />
760+
<span
761+
aria-hidden='true'
762+
className='-top-0.5 -right-0.5 absolute size-1.5 rounded-full bg-[var(--brand-primary)]'
763+
/>
730764
)}
731765
</span>
732766
</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)