11'use client'
22
3- import { useCallback , useEffect , useRef , useState } from 'react'
3+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react'
44import { 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'
516import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
17+ import { useMothershipTabsStore } from '@/stores/mothership-tabs/store'
618import { 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