@@ -10,35 +10,41 @@ import {
1010 useMemo ,
1111 useRef ,
1212 useState ,
13+ useSyncExternalStore ,
1314} from 'react'
14- import { Button , cn } from '@sim/emcn'
15+ import { Button , cn , toast } from '@sim/emcn'
1516import { PanelLeft } from '@sim/emcn/icons'
1617import { createLogger } from '@sim/logger'
18+ import { useQueryClient } from '@tanstack/react-query'
1719import { useParams , useRouter } from 'next/navigation'
1820import { useQueryState } from 'nuqs'
1921import { usePostHog } from 'posthog-js/react'
2022import { requestJson } from '@/lib/api/client/request'
2123import { createWorkflowContract } from '@/lib/api/contracts'
22- import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2324import {
2425 LandingPromptStorage ,
2526 type LandingWorkflowSeed ,
2627 LandingWorkflowSeedStorage ,
28+ MothershipHandoffStorage ,
2729} from '@/lib/core/utils/browser-storage'
30+ import { isDesktopApp } from '@/lib/desktop'
2831import {
32+ addMothershipContexts ,
2933 MOTHERSHIP_SEND_MESSAGE_EVENT ,
3034 type MothershipSendMessageDetail ,
3135} from '@/lib/mothership/events'
3236import { captureEvent } from '@/lib/posthog/client'
3337import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
38+ import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
39+ import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
3440import { resourceParam , resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
3541import { useFolders } from '@/hooks/queries/folders'
3642import {
3743 useMarkMothershipChatRead ,
3844 useMothershipChatHistory ,
3945} from '@/hooks/queries/mothership-chats'
4046import { useWorkflows } from '@/hooks/queries/workflows'
41- import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
47+ import { getWorkspaceFilesQueryOptions , useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4248import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
4349import type { ChatContext } from '@/stores/panel'
4450import {
@@ -50,15 +56,17 @@ import {
5056 UserInput ,
5157 type UserInputHandle ,
5258} from './components'
53- import {
54- getMothershipUseChatOptions ,
55- useChat ,
56- useMothershipHandoff ,
57- useMothershipResize ,
58- } from './hooks'
59- import type { FileAttachmentForApi , MothershipResource , MothershipResourceType } from './types'
59+ import { getMothershipUseChatOptions , useChat , useMothershipResize } from './hooks'
60+ import type {
61+ FileAttachmentForApi ,
62+ MothershipResource ,
63+ MothershipResourceType ,
64+ WorkspaceResourceRef ,
65+ } from './types'
6066
6167const logger = createLogger ( 'Home' )
68+ const subscribeToDesktopApp = ( ) => ( ) => { }
69+ const getServerDesktopAppSnapshot = ( ) => false
6270
6371/**
6472 * The resource preview panel pulls in the file-viewer stack (rich-markdown
@@ -81,8 +89,14 @@ interface HomeProps {
8189
8290export function Home ( { chatId, userName, userId, tableViewsEnabled } : HomeProps ) {
8391 useOAuthReturnRouter ( )
92+ const isDesktop = useSyncExternalStore (
93+ subscribeToDesktopApp ,
94+ isDesktopApp ,
95+ getServerDesktopAppSnapshot
96+ )
8497 const { workspaceId } = useParams < { workspaceId : string } > ( )
8598 const router = useRouter ( )
99+ const queryClient = useQueryClient ( )
86100 /**
87101 * URL is the single source of truth for the selected resource. `Home` renders
88102 * client-side, so nuqs reads `?resource=` from the URL on mount — the same
@@ -196,18 +210,11 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
196210 const { isPending : isChatHistoryPending } = useMothershipChatHistory ( chatId )
197211 const { mutate : markRead } = useMarkMothershipChatRead ( workspaceId )
198212
199- const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize ( )
200-
201213 const [ isResourceCollapsed , setIsResourceCollapsed ] = useState ( true )
202214 const [ skipResourceTransition , setSkipResourceTransition ] = useState ( false )
203215 const isResourceCollapsedRef = useRef ( isResourceCollapsed )
204216 isResourceCollapsedRef . current = isResourceCollapsed
205217
206- const collapseResource = useCallback ( ( ) => {
207- clearWidth ( )
208- setIsResourceCollapsed ( true )
209- } , [ clearWidth ] )
210-
211218 function handleResourceEvent ( ) {
212219 if ( isResourceCollapsedRef . current ) {
213220 setIsResourceCollapsed ( false )
@@ -221,6 +228,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
221228 sendMessage,
222229 stopGeneration,
223230 resolvedChatId,
231+ desktopScopeId,
224232 resources,
225233 activeResourceId,
226234 setActiveResourceId,
@@ -254,7 +262,12 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
254262 } )
255263 )
256264
257- useMothershipHandoff ( { chatId, workspaceId, sendMessage } )
265+ const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize ( desktopScopeId )
266+
267+ const collapseResource = useCallback ( ( ) => {
268+ clearWidth ( )
269+ setIsResourceCollapsed ( true )
270+ } , [ clearWidth ] )
258271
259272 useEffect ( ( ) => {
260273 wasSendingRef . current = false
@@ -381,60 +394,101 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
381394 return context . knowledgeId ? { type : 'knowledgebase' , id : context . knowledgeId } : null
382395 case 'table' :
383396 return context . tableId ? { type : 'table' , id : context . tableId } : null
397+ case 'table_selection' :
398+ return context . tableId ? { type : 'table' , id : context . tableId } : null
384399 case 'file' :
385400 return context . fileId ? { type : 'file' , id : context . fileId } : null
401+ case 'file_selection' :
402+ return context . fileId ? { type : 'file' , id : context . fileId } : null
386403 default :
387404 return null
388405 }
389406 }
390407
408+ /**
409+ * Tab title for the resource a chip opens. A selection chip's label describes
410+ * the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the
411+ * whole file/table, so title it from the resource name the context carries.
412+ */
413+ function resourceTitleForContext ( context : ChatContext ) : string {
414+ if ( context . kind === 'file_selection' ) return context . fileName
415+ if ( context . kind === 'table_selection' ) return context . tableName
416+ return context . label
417+ }
418+
391419 function handleContextAdd ( context : ChatContext ) {
392420 const resolved = resolveResourceFromContext ( context )
393421 if ( resolved ) {
394- addResource ( { ...resolved , title : context . label } )
422+ addResource ( { ...resolved , title : resourceTitleForContext ( context ) } )
395423 handleResourceEvent ( )
396424 }
397425 }
398426
399- function handleInitialContextRemove ( context : ChatContext ) {
427+ function handleInitialContextRemove ( context : ChatContext , remaining : ChatContext [ ] ) {
400428 const resolved = resolveResourceFromContext ( context )
401429 if ( ! resolved ) return
430+ // A whole-file chip and one or more of its selection chips (or several
431+ // selections of the same file/table) all resolve to the same resource tab.
432+ // Only close the tab once no remaining chip still references it, so removing
433+ // one of several chips doesn't yank a slideover the others still point at.
434+ const stillReferenced = remaining . some ( ( other ) => {
435+ const otherResolved = resolveResourceFromContext ( other )
436+ return otherResolved ?. type === resolved . type && otherResolved . id === resolved . id
437+ } )
438+ if ( stillReferenced ) return
402439 removeResource ( resolved . type , resolved . id )
403440 }
404441
405- const resolveFileResource = useCallback (
406- ( resource : MothershipResource ) : MothershipResource => {
407- if ( resource . type !== 'file' ) return resource
408-
409- const reference = ( resource . path || resource . id ) . trim ( )
410-
411- const file = workspaceFiles . find ( ( candidate ) => {
412- const candidatePath = canonicalWorkspaceFilePath ( {
413- folderPath : candidate . folderPath ,
414- name : candidate . name ,
415- } )
416- return candidate . id === reference || candidatePath === reference
417- } )
418-
419- if ( ! file ) return resource
420- return {
421- ...resource ,
422- id : file . id ,
423- title : resource . title || file . name ,
424- }
425- } ,
426- [ workspaceFiles ]
427- )
428-
429- function handleWorkspaceResourceSelect ( resource : MothershipResource ) {
430- const resolvedResource = resolveFileResource ( resource )
431- const wasAdded = addResource ( resolvedResource )
442+ function openWorkspaceResource ( resource : MothershipResource ) {
443+ const wasAdded = addResource ( resource )
432444 if ( ! wasAdded ) {
433- setActiveResourceId ( resolvedResource . id )
445+ setActiveResourceId ( resource . id )
434446 }
435447 handleResourceEvent ( )
436448 }
437449
450+ /**
451+ * Opens the resource a message chip points at, resolving it first. A chip may
452+ * carry only a filename — the agent names a file before the client's file
453+ * list knows it exists — so one forced refetch closes that window. What still
454+ * resolves to nothing opens nothing, rather than a tab that cannot be
455+ * viewed or removed.
456+ */
457+ async function handleWorkspaceResourceSelect ( ref : WorkspaceResourceRef ) {
458+ const immediate = resolveWorkspaceResourceRef ( ref , workspaceFiles )
459+ if ( immediate ) {
460+ openWorkspaceResource ( immediate )
461+ return
462+ }
463+ if ( ref . type !== 'file' ) return
464+
465+ // `staleTime: 0` forces the fetch this branch exists for — the cached list
466+ // is what already failed to resolve. `fetchQuery` rejects on error and this
467+ // handler is invoked as a void callback, so failure becomes null rather
468+ // than an unhandled rejection — and stays distinct from an empty list, so
469+ // "we could not look" is never reported as "it is not there".
470+ const files = await queryClient
471+ . fetchQuery ( { ...getWorkspaceFilesQueryOptions ( workspaceId ) , staleTime : 0 } )
472+ . catch ( ( ) => null )
473+ const resolved = files && resolveWorkspaceResourceRef ( ref , files )
474+ if ( resolved ) {
475+ openWorkspaceResource ( resolved )
476+ return
477+ }
478+ // The chip looks clickable, so refusing silently reads as a broken button.
479+ toast . error (
480+ files
481+ ? `Couldn't find "${ ref . title } " in this workspace`
482+ : `Couldn't open "${ ref . title } " — check your connection and try again`
483+ )
484+ logger . warn ( 'Ignored a resource chip that did not resolve' , {
485+ type : ref . type ,
486+ title : ref . title ,
487+ hasPath : Boolean ( ref . path ) ,
488+ reachedWorkspace : files !== null ,
489+ } )
490+ }
491+
438492 const hasMessages = messages . length > 0
439493 const showChatSkeleton = Boolean ( chatId ) && ! hasMessages && isChatHistoryPending
440494 const draftScopeKey = `${ workspaceId } :${ chatId ?? 'new' } `
@@ -446,15 +500,16 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
446500 const showEmptyState = ! hasMessages && ! showChatSkeleton
447501
448502 return (
449- < div className = 'relative flex h-full bg-[var(--bg)]' >
450- < div className = 'relative flex h-full min-w-[320px] flex-1 flex-col' >
451- { /* Clears the expand button when the panel is closed and that button is
452- occupying the same corner. */ }
503+ < div className = { cn ( 'relative flex h-full bg-[var(--bg)]' , RESOURCE_HEADER_CLASSES . layout ) } >
504+ < div className = 'relative flex h-full min-w-[240px] flex-1 flex-col' >
453505 { showEmptyState && (
454506 < div
455507 className = { cn (
456- 'absolute top-[8.5px] z-10' ,
457- isResourceCollapsed ? 'right-[54px]' : 'right-[16px]'
508+ 'absolute z-10' ,
509+ RESOURCE_HEADER_CLASSES . contentTop ,
510+ isDesktop || isResourceCollapsed
511+ ? RESOURCE_HEADER_CLASSES . adjacentEndPosition
512+ : RESOURCE_HEADER_CLASSES . endPosition
458513 ) }
459514 >
460515 < CreditsChip />
@@ -464,10 +519,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
464519 < div className = 'h-full overflow-y-auto [scrollbar-gutter:stable_both-edges]' >
465520 { /* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */ }
466521 < div className = 'flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]' >
467- < h1 className = 'mb-7 max-w-[48rem] text-balance font-season text-[30px ] text-[var(--text-primary)]' >
522+ < h1 className = 'mb-7 max-w-chat text-balance font-season text-[26px ] text-[var(--text-primary)] leading-[1.15] tracking-[-0.01em] sm:text-[28px ]' >
468523 What should we get done{ firstName ? `, ${ firstName } ` : '' } ?
469524 </ h1 >
470- < div ref = { initialViewInputRef } className = 'relative w-full max-w-[48rem] ' >
525+ < div ref = { initialViewInputRef } className = 'relative w-full max-w-chat ' >
471526 < ChatSurfaceProvider
472527 userId = { userId }
473528 onContextAdd = { handleContextAdd }
@@ -545,9 +600,11 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
545600 ref = { mothershipRef }
546601 workspaceId = { workspaceId }
547602 chatId = { resolvedChatId }
603+ desktopScopeId = { desktopScopeId }
548604 resources = { resources }
549605 activeResourceId = { activeResourceId }
550606 isCollapsed = { isResourceCollapsed }
607+ useFixedResourceToggle = { isDesktop }
551608 previewSession = { previewSession }
552609 isAgentResponding = { isSending }
553610 genericResourceData = { genericResourceData ?? undefined }
@@ -557,19 +614,46 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
557614 </ Suspense >
558615 </ MothershipResourcesProvider >
559616
560- { isResourceCollapsed && (
561- < div className = 'absolute top-[8.5px] right-[16px]' >
617+ { isDesktop ? (
618+ < div
619+ className = { cn (
620+ 'absolute top-0 z-30 flex items-center' ,
621+ RESOURCE_HEADER_CLASSES . controls ,
622+ RESOURCE_HEADER_CLASSES . endPosition
623+ ) }
624+ >
562625 < Button
563626 variant = 'ghost'
564627 size = { null }
565628 type = 'button'
566- onClick = { ( ) => setIsResourceCollapsed ( false ) }
629+ onClick = { isResourceCollapsed ? ( ) => setIsResourceCollapsed ( false ) : collapseResource }
567630 className = 'size-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
568- aria-label = 'Expand resource view'
631+ aria-label = { isResourceCollapsed ? 'Expand resource view' : 'Collapse resource view' }
569632 >
570- < PanelLeft className = 'size-[16px] text-[var(--text-icon)]' />
633+ < PanelLeft className = '-scale-x-100 size-[16px] text-[var(--text-icon)]' />
571634 </ Button >
572635 </ div >
636+ ) : (
637+ isResourceCollapsed && (
638+ < div
639+ className = { cn (
640+ 'absolute' ,
641+ RESOURCE_HEADER_CLASSES . contentTop ,
642+ RESOURCE_HEADER_CLASSES . endPosition
643+ ) }
644+ >
645+ < Button
646+ variant = 'ghost'
647+ size = { null }
648+ type = 'button'
649+ onClick = { ( ) => setIsResourceCollapsed ( false ) }
650+ className = 'size-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
651+ aria-label = 'Expand resource view'
652+ >
653+ < PanelLeft className = 'size-[16px] text-[var(--text-icon)]' />
654+ </ Button >
655+ </ div >
656+ )
573657 ) }
574658 </ div >
575659 )
0 commit comments