d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type Dispatch,
|
|
lazy,
|
|
type SetStateAction,
|
|
Suspense,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { Button } from '@sim/emcn'
|
|
import { PanelLeft } from '@sim/emcn/icons'
|
|
import { createLogger } from '@sim/logger'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { useQueryState } from 'nuqs'
|
|
import { usePostHog } from 'posthog-js/react'
|
|
import { requestJson } from '@/lib/api/client/request'
|
|
import { createWorkflowContract } from '@/lib/api/contracts'
|
|
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
|
import {
|
|
buildWorkflowAliasWorkflowEntries,
|
|
resolveWorkflowAliasPath,
|
|
resolveWorkspacePlanAliasPath,
|
|
} from '@/lib/copilot/vfs/workflow-aliases'
|
|
import {
|
|
LandingPromptStorage,
|
|
type LandingWorkflowSeed,
|
|
LandingWorkflowSeedStorage,
|
|
MothershipHandoffStorage,
|
|
} from '@/lib/core/utils/browser-storage'
|
|
import {
|
|
MOTHERSHIP_SEND_MESSAGE_EVENT,
|
|
type MothershipSendMessageDetail,
|
|
} from '@/lib/mothership/events'
|
|
import { captureEvent } from '@/lib/posthog/client'
|
|
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
|
|
import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params'
|
|
import { useFolders } from '@/hooks/queries/folders'
|
|
import {
|
|
useMarkMothershipChatRead,
|
|
useMothershipChatHistory,
|
|
} from '@/hooks/queries/mothership-chats'
|
|
import { useWorkflows } from '@/hooks/queries/workflows'
|
|
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
|
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
|
|
import type { ChatContext } from '@/stores/panel'
|
|
import {
|
|
ChatSurfaceProvider,
|
|
CreditsChip,
|
|
MothershipChat,
|
|
MothershipResourcesProvider,
|
|
SuggestedActions,
|
|
UserInput,
|
|
type UserInputHandle,
|
|
} from './components'
|
|
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
|
|
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
|
|
|
|
const logger = createLogger('Home')
|
|
|
|
/**
|
|
* The resource preview panel pulls in the file-viewer stack (rich-markdown
|
|
* editor, CSV/PDF viewers). It only renders once a chat has messages, so it is
|
|
* code-split out of the initial `/chat` bundle and loaded on demand.
|
|
*/
|
|
const MothershipView = lazy(() =>
|
|
import('./components/mothership-view/mothership-view').then((m) => ({
|
|
default: m.MothershipView,
|
|
}))
|
|
)
|
|
|
|
interface HomeProps {
|
|
chatId?: string
|
|
userName?: string
|
|
userId?: string
|
|
}
|
|
|
|
export function Home({ chatId, userName, userId }: HomeProps) {
|
|
useOAuthReturnRouter()
|
|
const { workspaceId } = useParams<{ workspaceId: string }>()
|
|
const router = useRouter()
|
|
/**
|
|
* URL is the single source of truth for the selected resource. `Home` renders
|
|
* client-side, so nuqs reads `?resource=` from the URL on mount — the same
|
|
* value the page previously threaded through `initialResourceId` — and writes
|
|
* it back with `history: 'replace'`, the previous behavior, minus the banned
|
|
* `window.history.replaceState` param-mutation effect. The page wraps `Home`
|
|
* in Suspense for the `useSearchParams` requirement.
|
|
*/
|
|
const [activeResourceParam, setResourceParam] = useQueryState(resourceParam.key, {
|
|
...resourceParam.parser,
|
|
...resourceUrlKeys,
|
|
})
|
|
/**
|
|
* Strips any leftover URL fragment on selection change, preserving the old
|
|
* effect's `url.hash = ''` (the only hash usage on this surface) without a
|
|
* separate effect-sync mirror. This rewrites the fragment only — it never
|
|
* mutates a query param via the History API.
|
|
*
|
|
* Order matters: the fragment is stripped synchronously BEFORE the nuqs write,
|
|
* because nuqs re-appends `location.hash` on its (deferred) flush — clearing the
|
|
* hash first ensures the param write doesn't carry the stale fragment back.
|
|
*/
|
|
const setActiveResourceUrl = useCallback<Dispatch<SetStateAction<string | null>>>(
|
|
(action) => {
|
|
if (typeof window !== 'undefined' && window.location.hash) {
|
|
const { pathname, search } = window.location
|
|
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
|
|
}
|
|
void setResourceParam(action)
|
|
},
|
|
[setResourceParam]
|
|
)
|
|
/**
|
|
* Controlled binding handed to `useChat` so the URL is the sole owner of the
|
|
* selection with no dual source.
|
|
*/
|
|
const activeResourceState = useMemo<[string | null, Dispatch<SetStateAction<string | null>>]>(
|
|
() => [activeResourceParam, setActiveResourceUrl],
|
|
[activeResourceParam, setActiveResourceUrl]
|
|
)
|
|
const firstName = userName?.split(' ')[0] ?? ''
|
|
const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId)
|
|
const { data: workflows = [] } = useWorkflows(workspaceId)
|
|
const { data: folders = [] } = useFolders(workspaceId)
|
|
const posthog = usePostHog()
|
|
const posthogRef = useRef(posthog)
|
|
posthogRef.current = posthog
|
|
const [initialPrompt, setInitialPrompt] = useState('')
|
|
const hasCheckedLandingStorageRef = useRef(false)
|
|
const initialViewInputRef = useRef<HTMLDivElement>(null)
|
|
const initialViewUserInputRef = useRef<UserInputHandle>(null)
|
|
|
|
const [isInputEntering, setIsInputEntering] = useState(false)
|
|
|
|
const createWorkflowFromLandingSeed = useCallback(
|
|
async (seed: LandingWorkflowSeed) => {
|
|
try {
|
|
const result = await persistImportedWorkflow({
|
|
content: seed.workflowJson,
|
|
filename: `${seed.workflowName}.json`,
|
|
workspaceId,
|
|
nameOverride: seed.workflowName,
|
|
descriptionOverride: seed.workflowDescription || 'Imported from landing template',
|
|
createWorkflow: async ({ name, description, workspaceId }) => {
|
|
return requestJson(createWorkflowContract, {
|
|
body: {
|
|
name,
|
|
description,
|
|
workspaceId,
|
|
deduplicate: true,
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
if (result?.workflowId) {
|
|
window.location.href = `/workspace/${workspaceId}/w/${result.workflowId}`
|
|
return
|
|
}
|
|
|
|
logger.warn('Landing workflow seed did not produce a workflow', {
|
|
templateId: seed.templateId,
|
|
})
|
|
} catch (error) {
|
|
logger.error('Error creating workflow from landing workflow seed:', error)
|
|
}
|
|
},
|
|
[workspaceId]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (hasCheckedLandingStorageRef.current) return
|
|
hasCheckedLandingStorageRef.current = true
|
|
|
|
const workflowSeed = LandingWorkflowSeedStorage.consume()
|
|
if (workflowSeed) {
|
|
logger.info('Retrieved landing page workflow seed, creating workflow in workspace')
|
|
void createWorkflowFromLandingSeed(workflowSeed)
|
|
return
|
|
}
|
|
|
|
const prompt = LandingPromptStorage.consume()
|
|
if (prompt) {
|
|
logger.info('Retrieved landing page prompt, populating home input')
|
|
setInitialPrompt(prompt)
|
|
}
|
|
}, [createWorkflowFromLandingSeed])
|
|
|
|
const wasSendingRef = useRef(false)
|
|
|
|
const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId)
|
|
const { mutate: markRead } = useMarkMothershipChatRead(workspaceId)
|
|
|
|
const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize()
|
|
|
|
const [isResourceCollapsed, setIsResourceCollapsed] = useState(true)
|
|
const [skipResourceTransition, setSkipResourceTransition] = useState(false)
|
|
const isResourceCollapsedRef = useRef(isResourceCollapsed)
|
|
isResourceCollapsedRef.current = isResourceCollapsed
|
|
|
|
const collapseResource = useCallback(() => {
|
|
clearWidth()
|
|
setIsResourceCollapsed(true)
|
|
}, [clearWidth])
|
|
|
|
function handleResourceEvent() {
|
|
if (isResourceCollapsedRef.current) {
|
|
setIsResourceCollapsed(false)
|
|
}
|
|
}
|
|
|
|
const {
|
|
messages,
|
|
isSending,
|
|
isReconnecting,
|
|
sendMessage,
|
|
stopGeneration,
|
|
resolvedChatId,
|
|
resources,
|
|
activeResourceId,
|
|
setActiveResourceId,
|
|
addResource,
|
|
removeResource,
|
|
reorderResources,
|
|
messageQueue,
|
|
removeFromQueue,
|
|
sendNow,
|
|
editQueuedMessage,
|
|
cancelQueueEdit,
|
|
editingQueuedId,
|
|
dispatchingHeadId,
|
|
previewSession,
|
|
genericResourceData,
|
|
getCurrentRequestId,
|
|
} = useChat(
|
|
workspaceId,
|
|
chatId,
|
|
getMothershipUseChatOptions({
|
|
onResourceEvent: handleResourceEvent,
|
|
activeResourceState,
|
|
onRequestStarted: ({ requestId, userMessageId }) => {
|
|
captureEvent(posthogRef.current, 'task_request_started', {
|
|
workspace_id: workspaceId,
|
|
view: 'mothership',
|
|
request_id: requestId,
|
|
user_message_id: userMessageId,
|
|
})
|
|
},
|
|
})
|
|
)
|
|
|
|
useEffect(() => {
|
|
wasSendingRef.current = false
|
|
if (resolvedChatId) {
|
|
markRead(resolvedChatId)
|
|
} else {
|
|
clearWidth()
|
|
setIsResourceCollapsed(true)
|
|
}
|
|
}, [resolvedChatId, markRead, clearWidth])
|
|
|
|
useEffect(() => {
|
|
if (wasSendingRef.current && !isSending && resolvedChatId) {
|
|
markRead(resolvedChatId)
|
|
}
|
|
wasSendingRef.current = isSending
|
|
}, [isSending, resolvedChatId, markRead])
|
|
|
|
useEffect(() => {
|
|
if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
|
|
setIsResourceCollapsed(false)
|
|
setSkipResourceTransition(true)
|
|
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
|
|
return () => cancelAnimationFrame(id)
|
|
}, [resources])
|
|
|
|
useEffect(() => {
|
|
if (resources.length === 0 && !isResourceCollapsedRef.current) {
|
|
collapseResource()
|
|
}
|
|
}, [resources, collapseResource])
|
|
|
|
const handleStopGeneration = useCallback(() => {
|
|
captureEvent(posthogRef.current, 'task_generation_aborted', {
|
|
workspace_id: workspaceId,
|
|
view: 'mothership',
|
|
request_id: getCurrentRequestId(),
|
|
})
|
|
void stopGeneration().catch(() => {})
|
|
}, [workspaceId, getCurrentRequestId, stopGeneration])
|
|
|
|
const handleSubmit = useCallback(
|
|
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
|
|
const trimmed = text.trim()
|
|
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
|
|
|
|
captureEvent(posthogRef.current, 'task_message_sent', {
|
|
workspace_id: workspaceId,
|
|
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
|
|
has_contexts: !!(contexts && contexts.length > 0),
|
|
is_new_task: !chatId,
|
|
})
|
|
|
|
if (initialViewInputRef.current) {
|
|
setIsInputEntering(true)
|
|
}
|
|
|
|
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
|
|
},
|
|
[workspaceId, chatId, sendMessage]
|
|
)
|
|
|
|
/**
|
|
* Handles cross-surface send requests (terminal/console "Fix in Chat", the
|
|
* log "Troubleshoot in Chat" action). `preventDefault` claims the event so a
|
|
* producer that dispatched it while this chat is mounted knows a live chat
|
|
* consumed the message and skips its navigate-and-persist fallback.
|
|
*/
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent<MothershipSendMessageDetail>).detail
|
|
if (!detail?.message) return
|
|
e.preventDefault()
|
|
sendMessage(detail.message, undefined, detail.contexts)
|
|
}
|
|
window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
|
|
return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler)
|
|
}, [sendMessage])
|
|
|
|
/**
|
|
* Consumes a one-shot handoff left by another surface (e.g. "Troubleshoot in
|
|
* Chat" on an errored log viewed from a different route) and auto-sends it
|
|
* into this fresh chat, tagging the run so Sim can inspect the failure. Only
|
|
* the cross-route path lands here — when a chat is already mounted the event
|
|
* above delivers directly. Gated to the new-chat surface (`!chatId`): a
|
|
* handoff always targets a fresh chat, so an existing `/chat/[chatId]` mount
|
|
* must never claim it if navigation races. `consume` clears the entry
|
|
* atomically, so it fires at most once even across a StrictMode remount.
|
|
*/
|
|
useEffect(() => {
|
|
if (chatId) return
|
|
const handoff = MothershipHandoffStorage.consume(workspaceId)
|
|
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
|
|
}, [chatId, workspaceId, sendMessage])
|
|
|
|
function resolveResourceFromContext(
|
|
context: ChatContext
|
|
): { type: MothershipResourceType; id: string } | null {
|
|
switch (context.kind) {
|
|
case 'workflow':
|
|
case 'current_workflow':
|
|
return context.workflowId ? { type: 'workflow', id: context.workflowId } : null
|
|
case 'knowledge':
|
|
return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null
|
|
case 'table':
|
|
return context.tableId ? { type: 'table', id: context.tableId } : null
|
|
case 'file':
|
|
return context.fileId ? { type: 'file', id: context.fileId } : null
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function handleContextAdd(context: ChatContext) {
|
|
const resolved = resolveResourceFromContext(context)
|
|
if (resolved) {
|
|
addResource({ ...resolved, title: context.label })
|
|
handleResourceEvent()
|
|
}
|
|
}
|
|
|
|
function handleInitialContextRemove(context: ChatContext) {
|
|
const resolved = resolveResourceFromContext(context)
|
|
if (!resolved) return
|
|
removeResource(resolved.type, resolved.id)
|
|
}
|
|
|
|
const workflowAliasEntries = useMemo(
|
|
() =>
|
|
buildWorkflowAliasWorkflowEntries(
|
|
workflows.map((workflow) => ({
|
|
id: workflow.id,
|
|
name: workflow.name,
|
|
folderId: workflow.folderId ?? null,
|
|
})),
|
|
folders.map((folder) => ({
|
|
folderId: folder.id,
|
|
folderName: folder.name,
|
|
parentId: folder.parentId ?? null,
|
|
}))
|
|
),
|
|
[folders, workflows]
|
|
)
|
|
|
|
const resolveFileResource = useCallback(
|
|
(resource: MothershipResource): MothershipResource => {
|
|
if (resource.type !== 'file') return resource
|
|
|
|
const reference = (resource.path || resource.id).trim()
|
|
const workspacePlanAlias = resolveWorkspacePlanAliasPath(reference)
|
|
const workflowAlias = workspacePlanAlias
|
|
? null
|
|
: resolveWorkflowAliasPath(reference, workflowAliasEntries)
|
|
const alias = workspacePlanAlias || workflowAlias
|
|
const targetPath = alias && alias.kind !== 'plans_dir' ? alias.backingPath : reference
|
|
|
|
const file = workspaceFiles.find((candidate) => {
|
|
const candidatePath = canonicalWorkspaceFilePath({
|
|
folderPath: candidate.folderPath,
|
|
name: candidate.name,
|
|
})
|
|
return (
|
|
candidate.id === reference || candidatePath === reference || candidatePath === targetPath
|
|
)
|
|
})
|
|
|
|
if (!file) return resource
|
|
return {
|
|
...resource,
|
|
id: file.id,
|
|
title: resource.title || file.name,
|
|
path: alias ? reference : resource.path,
|
|
}
|
|
},
|
|
[workflowAliasEntries, workspaceFiles]
|
|
)
|
|
|
|
function handleWorkspaceResourceSelect(resource: MothershipResource) {
|
|
const resolvedResource = resolveFileResource(resource)
|
|
const wasAdded = addResource(resolvedResource)
|
|
if (!wasAdded) {
|
|
setActiveResourceId(resolvedResource.id)
|
|
}
|
|
handleResourceEvent()
|
|
}
|
|
|
|
const hasMessages = messages.length > 0
|
|
const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
|
|
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
|
|
|
|
if (!hasMessages && !showChatSkeleton) {
|
|
return (
|
|
<div className='relative h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable_both-edges]'>
|
|
<div className='absolute top-[8.5px] right-[16px] z-10'>
|
|
<CreditsChip />
|
|
</div>
|
|
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
|
|
<div className='flex min-h-full flex-col items-center justify-center px-6 pt-[2vh] pb-[22vh]'>
|
|
<h1 className='mb-7 max-w-[48rem] text-balance font-season text-[30px] text-[var(--text-primary)]'>
|
|
What should we get done{firstName ? `, ${firstName}` : ''}?
|
|
</h1>
|
|
<div ref={initialViewInputRef} className='relative w-full max-w-[48rem]'>
|
|
<ChatSurfaceProvider
|
|
userId={userId}
|
|
onContextAdd={handleContextAdd}
|
|
onContextRemove={handleInitialContextRemove}
|
|
>
|
|
<UserInput
|
|
ref={initialViewUserInputRef}
|
|
defaultValue={initialPrompt}
|
|
draftScopeKey={draftScopeKey}
|
|
onSubmit={handleSubmit}
|
|
isSending={isSending}
|
|
onStopGeneration={handleStopGeneration}
|
|
/>
|
|
</ChatSurfaceProvider>
|
|
{/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
|
|
<div className='absolute inset-x-0 top-full'>
|
|
<SuggestedActions
|
|
onSelectPrompt={(prompt) => initialViewUserInputRef.current?.populatePrompt(prompt)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className='relative flex h-full bg-[var(--bg)]'>
|
|
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
|
|
<MothershipChat
|
|
messages={messages}
|
|
isSending={isSending}
|
|
isReconnecting={isReconnecting}
|
|
isLoading={showChatSkeleton}
|
|
onSubmit={handleSubmit}
|
|
onStopGeneration={handleStopGeneration}
|
|
messageQueue={messageQueue}
|
|
editingQueuedId={editingQueuedId}
|
|
dispatchingHeadId={dispatchingHeadId}
|
|
onRemoveQueuedMessage={removeFromQueue}
|
|
onSendQueuedMessage={sendNow}
|
|
onEditQueuedMessage={editQueuedMessage}
|
|
onCancelQueueEdit={cancelQueueEdit}
|
|
userId={userId}
|
|
chatId={resolvedChatId}
|
|
onContextAdd={handleContextAdd}
|
|
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
|
|
draftScopeKey={draftScopeKey}
|
|
animateInput={isInputEntering}
|
|
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
|
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
|
/>
|
|
</div>
|
|
|
|
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}
|
|
{!isResourceCollapsed && (
|
|
<div className='relative z-20 w-0 flex-none'>
|
|
<div
|
|
className='absolute inset-y-0 left-[-4px] w-[8px] cursor-ew-resize'
|
|
role='separator'
|
|
aria-orientation='vertical'
|
|
aria-label='Resize resource panel'
|
|
onPointerDown={handleResizePointerDown}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<MothershipResourcesProvider
|
|
selectResource={setActiveResourceId}
|
|
addResource={addResource}
|
|
removeResource={removeResource}
|
|
reorderResources={reorderResources}
|
|
collapseResource={collapseResource}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<MothershipView
|
|
ref={mothershipRef}
|
|
workspaceId={workspaceId}
|
|
chatId={resolvedChatId}
|
|
resources={resources}
|
|
activeResourceId={activeResourceId}
|
|
isCollapsed={isResourceCollapsed}
|
|
previewSession={previewSession}
|
|
isAgentResponding={isSending}
|
|
genericResourceData={genericResourceData ?? undefined}
|
|
className={skipResourceTransition ? '!transition-none' : undefined}
|
|
/>
|
|
</Suspense>
|
|
</MothershipResourcesProvider>
|
|
|
|
{isResourceCollapsed && (
|
|
<div className='absolute top-[8.5px] right-[16px]'>
|
|
<Button
|
|
variant='ghost'
|
|
size={null}
|
|
type='button'
|
|
onClick={() => setIsResourceCollapsed(false)}
|
|
className='size-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
|
|
aria-label='Expand resource view'
|
|
>
|
|
<PanelLeft className='size-[16px] text-[var(--text-icon)]' />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|