Skip to content

Commit b1d3536

Browse files
andresdjassoclaude
andcommitted
feat(workflow): stage stack — resources card over the editor, never instead of it
When a docked chat touches or links a non-workflow resource, the workspace resource panel (same tab strip as everywhere) slides in as a card IN FRONT of the editor with toast-stack depth: the workflow's title strip peeks above and stays live behind. Clicking the peek, the card's ×, or pressing Escape brings the editor forward; closing the last tab does too. The staged resource rides in ?resource= — deep links restore the stack, reconstructing page tabs from their self-describing ids, with hydration-gated effects so the transiently empty store can't strip a valid link. Replaces the v1 behavior of navigating back to the chat view (which threw away the editor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f593d1 commit b1d3536

2 files changed

Lines changed: 228 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/docked-chat/docked-chat.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect } from 'react'
3+
import { useEffect, useRef } from 'react'
44
import { useRouter } from 'next/navigation'
55
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
66
import { getMothershipUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
@@ -22,6 +22,11 @@ interface DockedChatProps {
2222
onSelectChat: (chatId: string) => void
2323
/** A new chat resolved its server id — host reflects it into the URL. */
2424
onChatResolved: (chatId: string) => void
25+
/**
26+
* A non-workflow resource needs the stage: the host stacks the resource
27+
* card in front of the editor instead of navigating away.
28+
*/
29+
onStageResource: (resource: MothershipResource) => void
2530
}
2631

2732
/**
@@ -38,8 +43,11 @@ export function DockedChat({
3843
onClose,
3944
onSelectChat,
4045
onChatResolved,
46+
onStageResource,
4147
}: DockedChatProps) {
4248
const router = useRouter()
49+
/** Readable from stream callbacks before the hook's return is in scope. */
50+
const activeChatIdRef = useRef<string | undefined>(chatId)
4351
const {
4452
messages,
4553
isSending,
@@ -54,13 +62,32 @@ export function DockedChat({
5462
cancelQueueEdit,
5563
editingQueuedId,
5664
dispatchingHeadId,
57-
} = useChat(workspaceId, chatId, getMothershipUseChatOptions({}))
65+
} = useChat(
66+
workspaceId,
67+
chatId,
68+
getMothershipUseChatOptions({
69+
// The stage follows the conversation: another workflow swaps the
70+
// editor; anything else stacks the resource card in front of it.
71+
onResourceTouched: (resource) => {
72+
if (resource.type === 'workflow') {
73+
if (resource.id === workflowId) return
74+
const chatParam = activeChatIdRef.current
75+
router.push(
76+
`/workspace/${workspaceId}/w/${resource.id}${chatParam ? `?chat=${chatParam}` : ''}`
77+
)
78+
return
79+
}
80+
onStageResource(resource)
81+
},
82+
})
83+
)
5884

5985
useEffect(() => {
6086
if (resolvedChatId && resolvedChatId !== chatId) onChatResolved(resolvedChatId)
6187
}, [resolvedChatId, chatId, onChatResolved])
6288

6389
const activeChatId = resolvedChatId ?? chatId
90+
activeChatIdRef.current = activeChatId
6491
const { isPending: isHistoryPending } = useMothershipChatHistory(activeChatId)
6592
const showSkeleton = Boolean(activeChatId) && messages.length === 0 && isHistoryPending
6693

@@ -81,8 +108,7 @@ export function DockedChat({
81108
router.push(`/workspace/${workspaceId}/w/${resource.id}${chatParam}`)
82109
return
83110
}
84-
if (!activeChatId) return
85-
router.push(`/workspace/${workspaceId}/chat/${activeChatId}?resource=${resource.id}`)
111+
onStageResource(resource)
86112
}
87113

88114
return (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/docked-chat/workflow-with-chat.tsx

Lines changed: 198 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
'use client'
22

3-
import { useCallback, useEffect, useRef, useState } from 'react'
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { useParams, useSearchParams } from 'next/navigation'
5+
import { Tooltip } from '@/components/emcn'
6+
import { X } from '@/components/emcn/icons'
7+
import { isMothershipPageId, MOTHERSHIP_PAGES } from '@/lib/copilot/resources/types'
8+
import {
9+
MothershipResourcesProvider,
10+
MothershipView,
11+
} from '@/app/workspace/[workspaceId]/home/components'
12+
import type {
13+
MothershipResource,
14+
MothershipResourceType,
15+
} from '@/app/workspace/[workspaceId]/home/types'
516
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
17+
import { useMothershipTabsStore } from '@/stores/mothership-tabs/store'
618
import { DockedChat } from './docked-chat'
719

820
/** Sentinel `?chat=` value for a docked chat that hasn't been created yet. */
@@ -37,33 +49,160 @@ export function WorkflowWithChat() {
3749
)
3850

3951
/** URL is a mirror, not a router concern — replaceState avoids remounts. */
40-
const reflectChatParam = useCallback((value: string | null) => {
52+
const reflectParam = useCallback((key: string, value: string | null) => {
4153
const url = new URL(window.location.href)
42-
if (value) url.searchParams.set('chat', value)
43-
else url.searchParams.delete('chat')
54+
if (value) url.searchParams.set(key, value)
55+
else url.searchParams.delete(key)
4456
window.history.replaceState(null, '', url.toString())
4557
}, [])
4658

59+
/** The chat actually in the pane (server id once a new chat resolves). */
60+
const activeChatIdRef = useRef<string | undefined>(dock.chatId)
61+
4762
const openChat = useCallback(
4863
(chatId?: string) => {
4964
setDock({ open: true, chatId })
50-
reflectChatParam(chatId ?? NEW_CHAT_PARAM)
65+
activeChatIdRef.current = chatId
66+
reflectParam('chat', chatId ?? NEW_CHAT_PARAM)
5167
},
52-
[reflectChatParam]
68+
[reflectParam]
5369
)
5470

5571
const closeChat = useCallback(() => {
5672
setDock({ open: false })
57-
reflectChatParam(null)
58-
}, [reflectChatParam])
73+
reflectParam('chat', null)
74+
}, [reflectParam])
5975

6076
/**
6177
* A new docked chat got its server id mid-conversation. Only the URL
6278
* updates — re-keying the pane here would remount the hook mid-stream.
6379
*/
6480
const handleChatResolved = useCallback(
65-
(chatId: string) => reflectChatParam(chatId),
66-
[reflectChatParam]
81+
(chatId: string) => {
82+
activeChatIdRef.current = chatId
83+
reflectParam('chat', chatId)
84+
},
85+
[reflectParam]
86+
)
87+
88+
// ── Stage stack ──────────────────────────────────────────────────────────
89+
// Non-workflow resources never replace the editor: they slide in as a card
90+
// IN FRONT of it (toast-stack depth), with the workflow's title strip
91+
// peeking above. Clicking the peek, the ×, or pressing Escape brings the
92+
// workflow forward. Tabs are the same workspace-owned strip as everywhere.
93+
const [stackOpen, setStackOpen] = useState<boolean>(() => Boolean(searchParams.get('resource')))
94+
const initialStageIdRef = useRef(searchParams.get('resource'))
95+
96+
const workspaceTabs = useMothershipTabsStore((s) =>
97+
workspaceId ? s.byWorkspace[workspaceId] : undefined
98+
)
99+
const openTabs = useMothershipTabsStore((s) => s.openTabs)
100+
const closeTab = useMothershipTabsStore((s) => s.closeTab)
101+
const reorderTabs = useMothershipTabsStore((s) => s.reorderTabs)
102+
const setActiveTab = useMothershipTabsStore((s) => s.setActiveTab)
103+
const stageTabs = useMemo(
104+
() => (workspaceTabs?.tabs ?? []).filter((tab) => tab.type !== 'workflow'),
105+
[workspaceTabs]
106+
)
107+
const stageActiveId = workspaceTabs?.activeTabId ?? null
108+
109+
const stageResource = useCallback(
110+
(resource: MothershipResource) => {
111+
if (!workspaceId) return
112+
openTabs(workspaceId, [resource], { focusId: resource.id })
113+
setStackOpen(true)
114+
reflectParam('resource', resource.id)
115+
},
116+
[openTabs, workspaceId, reflectParam]
117+
)
118+
119+
const collapseStack = useCallback(() => {
120+
setStackOpen(false)
121+
reflectParam('resource', null)
122+
}, [reflectParam])
123+
124+
/**
125+
* Deep-link restore: focus the URL-pinned tab if the strip still has it.
126+
* The tabs store rehydrates asynchronously, so wait for hydration before
127+
* deciding the tab doesn't exist.
128+
*/
129+
useEffect(() => {
130+
const id = initialStageIdRef.current
131+
if (!id || !workspaceId) return
132+
const apply = () => {
133+
initialStageIdRef.current = null
134+
const tabs = useMothershipTabsStore.getState().byWorkspace[workspaceId]?.tabs ?? []
135+
if (tabs.some((tab) => tab.id === id)) {
136+
setActiveTab(workspaceId, id)
137+
return
138+
}
139+
// The strip doesn't have it, but page ids are self-describing — a deep
140+
// link to a workspace page reconstructs its tab instead of collapsing.
141+
if (isMothershipPageId(id)) {
142+
openTabs(workspaceId, [{ type: 'page', id, title: MOTHERSHIP_PAGES[id] }], {
143+
focusId: id,
144+
})
145+
}
146+
}
147+
if (useMothershipTabsStore.persist.hasHydrated()) {
148+
apply()
149+
return
150+
}
151+
return useMothershipTabsStore.persist.onFinishHydration(apply)
152+
}, [workspaceId, setActiveTab, openTabs])
153+
154+
useEffect(() => {
155+
if (!stackOpen) return
156+
const onKeyDown = (event: KeyboardEvent) => {
157+
if (event.key === 'Escape') collapseStack()
158+
}
159+
window.addEventListener('keydown', onKeyDown)
160+
return () => window.removeEventListener('keydown', onKeyDown)
161+
}, [stackOpen, collapseStack])
162+
163+
/**
164+
* Closing the last tab leaves nothing to show — the editor comes forward.
165+
* Reads the live store (the render closure lags one commit behind the
166+
* deep-link restore) and stays out until hydration and restore both ran,
167+
* since the strip is transiently empty before them.
168+
*/
169+
useEffect(() => {
170+
if (!stackOpen || stageTabs.length > 0) return
171+
if (!workspaceId) return
172+
if (!useMothershipTabsStore.persist.hasHydrated()) return
173+
if (initialStageIdRef.current) return
174+
const live = useMothershipTabsStore.getState().byWorkspace[workspaceId]?.tabs ?? []
175+
if (live.some((tab) => tab.type !== 'workflow')) return
176+
collapseStack()
177+
}, [stackOpen, stageTabs, collapseStack, workspaceId])
178+
179+
const selectStageTab = useCallback(
180+
(id: string) => {
181+
if (workspaceId) setActiveTab(workspaceId, id)
182+
},
183+
[setActiveTab, workspaceId]
184+
)
185+
186+
const addStageTab = useCallback(
187+
(resource: MothershipResource) => {
188+
if (resource.type === 'workflow') return
189+
stageResource(resource)
190+
},
191+
[stageResource]
192+
)
193+
194+
const removeStageTab = useCallback(
195+
(resourceType: MothershipResourceType, resourceId: string) => {
196+
if (workspaceId) closeTab(workspaceId, resourceType, resourceId)
197+
},
198+
[closeTab, workspaceId]
199+
)
200+
201+
const reorderStageTabs = useCallback(
202+
(tabs: MothershipResource[]) => {
203+
if (workspaceId) reorderTabs(workspaceId, tabs)
204+
},
205+
[reorderTabs, workspaceId]
67206
)
68207

69208
// Divider drag mirrors useMothershipResize (imperative width, pointer
@@ -146,6 +285,7 @@ export function WorkflowWithChat() {
146285
onClose={closeChat}
147286
onSelectChat={openChat}
148287
onChatResolved={handleChatResolved}
288+
onStageResource={stageResource}
149289
/>
150290
</div>
151291
{/* Zero-width flex child whose absolute child straddles the border. */}
@@ -160,12 +300,59 @@ export function WorkflowWithChat() {
160300
</div>
161301
</>
162302
)}
163-
<div className='h-full min-w-0 flex-1'>
303+
<div className='relative h-full min-w-0 flex-1'>
164304
<Workflow
165305
workspaceId={workspaceId}
166306
workflowId={workflowId}
167307
chatDock={{ isOpen: dock.open, onSelectChat: openChat }}
168308
/>
309+
{stackOpen && (
310+
<>
311+
{/* The peek: the editor's own title strip shows through this
312+
transparent band — clicking it brings the workflow forward. */}
313+
<button
314+
type='button'
315+
aria-label='Back to workflow'
316+
onClick={collapseStack}
317+
className='absolute inset-x-0 top-0 z-30 h-[44px] cursor-pointer'
318+
/>
319+
{/* The front card: the workspace resource tabs + active content,
320+
stacked over the editor with toast-stack depth. */}
321+
<div className='absolute inset-x-2 top-[44px] bottom-0 z-30 flex animate-slide-in-bottom flex-col overflow-hidden rounded-t-xl border border-[var(--border-1)] border-b-0 bg-[var(--bg)] shadow-sm'>
322+
<MothershipResourcesProvider
323+
selectResource={selectStageTab}
324+
addResource={addStageTab}
325+
removeResource={removeStageTab}
326+
reorderResources={reorderStageTabs}
327+
collapseResource={collapseStack}
328+
>
329+
<MothershipView
330+
workspaceId={workspaceId}
331+
chatId={activeChatIdRef.current}
332+
resources={stageTabs}
333+
activeResourceId={stageActiveId}
334+
isCollapsed={false}
335+
className='h-full w-full border-l-0'
336+
/>
337+
</MothershipResourcesProvider>
338+
<Tooltip.Root>
339+
<Tooltip.Trigger asChild>
340+
<button
341+
type='button'
342+
onClick={collapseStack}
343+
aria-label='Close resource view'
344+
className='absolute top-[7px] right-[9px] z-20 flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]'
345+
>
346+
<X className='size-[14px] text-[var(--text-icon)]' />
347+
</button>
348+
</Tooltip.Trigger>
349+
<Tooltip.Content side='bottom'>
350+
<p>Back to workflow</p>
351+
</Tooltip.Content>
352+
</Tooltip.Root>
353+
</div>
354+
</>
355+
)}
169356
</div>
170357
</div>
171358
)

0 commit comments

Comments
 (0)