Skip to content

Commit c1bd985

Browse files
committed
fix(search): port the palette to post-#6458 staging
Carries the former merge resolutions as one commit: the emcn icon set (SelectAll fit-to-view, Search chrome), native browser-panel occlusion gating, scheduled-tasks retirement, the chip-aware handoff consumer superseding the ?handoff=1 machinery, and Knowledge bases pluralization.
1 parent 16a6787 commit c1bd985

14 files changed

Lines changed: 368 additions & 605 deletions

File tree

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

Lines changed: 145 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
1516
import { PanelLeft } from '@sim/emcn/icons'
1617
import { createLogger } from '@sim/logger'
18+
import { useQueryClient } from '@tanstack/react-query'
1719
import { useParams, useRouter } from 'next/navigation'
1820
import { useQueryState } from 'nuqs'
1921
import { usePostHog } from 'posthog-js/react'
2022
import { requestJson } from '@/lib/api/client/request'
2123
import { createWorkflowContract } from '@/lib/api/contracts'
22-
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2324
import {
2425
LandingPromptStorage,
2526
type LandingWorkflowSeed,
2627
LandingWorkflowSeedStorage,
28+
MothershipHandoffStorage,
2729
} from '@/lib/core/utils/browser-storage'
30+
import { isDesktopApp } from '@/lib/desktop'
2831
import {
32+
addMothershipContexts,
2933
MOTHERSHIP_SEND_MESSAGE_EVENT,
3034
type MothershipSendMessageDetail,
3135
} from '@/lib/mothership/events'
3236
import { captureEvent } from '@/lib/posthog/client'
3337
import { 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'
3440
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
3541
import { useFolders } from '@/hooks/queries/folders'
3642
import {
3743
useMarkMothershipChatRead,
3844
useMothershipChatHistory,
3945
} from '@/hooks/queries/mothership-chats'
4046
import { useWorkflows } from '@/hooks/queries/workflows'
41-
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
47+
import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4248
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
4349
import type { ChatContext } from '@/stores/panel'
4450
import {
@@ -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

6167
const 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

8290
export 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
)

apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@ export {
33
getWorkflowCopilotUseChatOptions,
44
useChat,
55
} from './use-chat'
6-
export { useMothershipHandoff } from './use-mothership-handoff'
76
export { useMothershipResize } from './use-mothership-resize'

0 commit comments

Comments
 (0)