improvements: ui/ux around mothership

This commit is contained in:
Emir Karabeg
2026-03-13 19:15:08 -07:00
parent 0c9ab10b12
commit 709f91fd29
36 changed files with 782 additions and 1292 deletions

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
@@ -29,7 +29,7 @@ export async function POST(request: NextRequest) {
await db
.update(copilotChats)
.set({ lastSeenAt: new Date() })
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
return NextResponse.json({ success: true })

View File

@@ -3,17 +3,10 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallStatus } from '../../../../types'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { ToolCallItem } from './tool-call-item'
interface ToolCallData {
id: string
toolName: string
displayTitle: string
status: ToolCallStatus
}
interface AgentGroupProps {
agentName: string
agentLabel: string

View File

@@ -55,22 +55,14 @@ export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemPro
{status === 'executing' ? (
<Loader className='h-[16px] w-[16px] text-[var(--text-icon)]' animate />
) : status === 'cancelled' ? (
<CircleStop className='h-[16px] w-[16px] text-[var(--text-secondary)]' />
<CircleStop className='h-[16px] w-[16px] text-[var(--text-icon)]' />
) : Icon ? (
<Icon className='h-[16px] w-[16px] text-[var(--text-icon)]' />
) : (
<CircleCheck className='h-[16px] w-[16px] text-[var(--text-icon)]' />
)}
</div>
<span
className={
status === 'cancelled'
? 'font-base text-[14px] text-[var(--text-secondary)]'
: 'font-base text-[14px] text-[var(--text-body)]'
}
>
{displayTitle}
</span>
<span className='font-base text-[14px] text-[var(--text-body)]'>{displayTitle}</span>
</div>
)
}

View File

@@ -67,7 +67,7 @@ type ThProps = ComponentPropsWithoutRef<'th'>
const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['components'] = {
table({ children }) {
return (
<div className='not-prose my-4 w-full overflow-x-auto'>
<div className='not-prose my-4 w-full overflow-x-auto [&_strong]:font-[600]'>
<table className='min-w-full border-collapse'>{children}</table>
</div>
)
@@ -131,7 +131,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
)}
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[#1F1F1F]'>
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[13px] text-[var(--text-primary)] leading-[21px]'
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import type { ContentBlock, OptionItem, SubagentName, ToolCallStatus } from '../../types'
import type { ContentBlock, OptionItem, SubagentName, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import { AgentGroup, ChatContent, CircleStop, Options } from './components'
@@ -9,13 +9,6 @@ interface TextSegment {
content: string
}
interface ToolCallData {
id: string
toolName: string
displayTitle: string
status: ToolCallStatus
}
interface AgentGroupSegment {
type: 'agent_group'
id: string
@@ -252,8 +245,8 @@ export function MessageContent({
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-secondary)]' />
<span className='font-base text-[14px] text-[var(--text-secondary)]'>
<CircleStop className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='font-base text-[14px] text-[var(--text-body)]'>
Stopped by user
</span>
</div>

View File

@@ -1,33 +1,33 @@
'use client'
import { type RefCallback, useCallback, useMemo, useState } from 'react'
import { ChevronRight, Folder } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSearchInput,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Tooltip,
} from '@/components/emcn'
import { Plus, Search } from '@/components/emcn/icons'
import { Plus } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
export interface AddResourceDropdownProps {
workspaceId: string
@@ -43,12 +43,6 @@ interface AvailableItemsByType {
items: AvailableItem[]
}
const EMPTY_SUBMENU = (
<DropdownMenuItem disabled>
<span className='text-[13px] text-[var(--text-tertiary)]'>None available</span>
</DropdownMenuItem>
)
export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
@@ -99,159 +93,6 @@ export function useAvailableResources(
)
}
function CollapsibleFolder({
folder,
workflows,
expanded,
onToggle,
onSelect,
config,
level,
}: {
folder: FolderTreeNode
workflows: AvailableItem[]
expanded: Set<string>
onToggle: (id: string) => void
onSelect: (item: AvailableItem) => void
config: ReturnType<typeof getResourceConfig>
level: number
}) {
const folderWorkflows = workflows.filter((w) => (w.folderId as string | null) === folder.id)
const isExpanded = expanded.has(folder.id)
const indent = level * 12
return (
<>
<div
role='button'
tabIndex={0}
onClick={(e) => {
e.preventDefault()
onToggle(folder.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') onToggle(folder.id)
}}
className='flex cursor-pointer items-center gap-[6px] rounded-sm px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]'
style={{ paddingLeft: `${8 + indent}px` }}
>
<ChevronRight
className={cn(
'h-[12px] w-[12px] shrink-0 text-[var(--text-tertiary)] transition-transform duration-100',
isExpanded && 'rotate-90'
)}
/>
<Folder className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-primary)]'>{folder.name}</span>
</div>
{isExpanded && (
<>
{folder.children.map((child) => (
<CollapsibleFolder
key={child.id}
folder={child}
workflows={workflows}
expanded={expanded}
onToggle={onToggle}
onSelect={onSelect}
config={config}
level={level + 1}
/>
))}
{folderWorkflows.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => onSelect(item)}
style={{ paddingLeft: `${8 + (level + 1) * 12}px` }}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</>
)}
</>
)
}
function WorkflowSubmenuContent({
workspaceId,
items,
config,
onSelect,
}: {
workspaceId: string
items: AvailableItem[]
config: ReturnType<typeof getResourceConfig>
onSelect: (item: AvailableItem) => void
}) {
useFolders(workspaceId)
const folders = useFolderStore((state) => state.folders)
const getFolderTree = useFolderStore((state) => state.getFolderTree)
const folderTree = useMemo(
() => getFolderTree(workspaceId),
[folders, getFolderTree, workspaceId]
)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggleFolder = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const workflowsByFolder = useMemo(() => {
const grouped: Record<string, AvailableItem[]> = {}
for (const item of items) {
const fId = (item.folderId as string | null) ?? 'root'
if (!grouped[fId]) grouped[fId] = []
grouped[fId].push(item)
}
return grouped
}, [items])
const rootWorkflows = workflowsByFolder.root ?? []
const folderTreeHasItems = useCallback(
(folder: FolderTreeNode): boolean => {
if (workflowsByFolder[folder.id]?.length) return true
return folder.children.some(folderTreeHasItems)
},
[workflowsByFolder]
)
const visibleFolders = useMemo(
() => folderTree.filter(folderTreeHasItems),
[folderTree, folderTreeHasItems]
)
if (items.length === 0) return EMPTY_SUBMENU
return (
<>
{visibleFolders.map((folder) => (
<CollapsibleFolder
key={folder.id}
folder={folder}
workflows={items}
expanded={expanded}
onToggle={toggleFolder}
onSelect={onSelect}
config={config}
level={0}
/>
))}
{rootWorkflows.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => onSelect(item)}>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</>
)
}
export function AddResourceDropdown({
workspaceId,
existingKeys,
@@ -260,14 +101,15 @@ export function AddResourceDropdown({
}: AddResourceDropdownProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const available = useAvailableResources(workspaceId, existingKeys)
const inputRef = useCallback<RefCallback<HTMLInputElement>>((node) => {
if (node) setTimeout(() => node.focus(), 0)
}, [])
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
if (!next) setSearch('')
if (!next) {
setSearch('')
setActiveIndex(0)
}
}, [])
const select = useCallback(
@@ -279,20 +121,38 @@ export function AddResourceDropdown({
}
setOpen(false)
setSearch('')
setActiveIndex(0)
},
[onAdd, onSwitch]
)
const query = search.trim().toLowerCase()
const filtered = useMemo(() => {
if (!query) return null
const q = search.toLowerCase().trim()
if (!q) return null
return available.flatMap(({ type, items }) =>
items
.filter((item) => item.name.toLowerCase().includes(query))
.map((item) => ({ type, item }))
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
)
}, [available, query])
}, [search, available])
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!filtered) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((prev) => Math.min(prev + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((prev) => Math.max(prev - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (filtered.length > 0 && filtered[activeIndex]) {
const { type, item } = filtered[activeIndex]
select({ type, id: item.id, title: item.name }, item.isOpen)
}
}
},
[filtered, activeIndex, select]
)
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
@@ -301,10 +161,10 @@ export function AddResourceDropdown({
<DropdownMenuTrigger asChild>
<Button
variant='subtle'
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Add resource tab'
>
<Plus className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<Plus className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
@@ -312,27 +172,30 @@ export function AddResourceDropdown({
<p>Add resource</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent align='start' className='w-[240px]'>
<div className='flex items-center gap-[8px] px-[8px] py-[6px]'>
<Search className='h-[14px] w-[14px] shrink-0 text-[var(--text-tertiary)]' />
<input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder='Search resources…'
className='h-[20px] w-full bg-transparent text-[13px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]'
/>
</div>
<DropdownMenuSeparator />
{filtered ? (
filtered.length > 0 ? (
<div className='max-h-[280px] overflow-y-auto'>
{filtered.map(({ type, item }) => {
<DropdownMenuContent
align='start'
sideOffset={8}
className='flex w-[240px] flex-col overflow-hidden'
>
<DropdownMenuSearchInput
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
<div className='min-h-0 flex-1 overflow-y-auto'>
{filtered ? (
filtered.length > 0 ? (
filtered.map(({ type, item }, index) => {
const config = getResourceConfig(type)
return (
<DropdownMenuItem
key={`${type}:${item.id}`}
className={cn(index === activeIndex && 'bg-[var(--surface-active)]')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)}
>
{config.renderDropdownItem({ item })}
@@ -341,50 +204,53 @@ export function AddResourceDropdown({
</span>
</DropdownMenuItem>
)
})}
</div>
) : (
<div className='px-[8px] py-[6px] text-[13px] text-[var(--text-tertiary)]'>
No results
</div>
)
) : (
available.map(({ type, items }) => {
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='mr-[8px] h-[14px] w-[14px] text-[var(--text-icon)]' />
{config.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='max-h-[280px] w-[220px] overflow-y-auto'>
{type === 'workflow' ? (
<WorkflowSubmenuContent
workspaceId={workspaceId}
items={items}
config={config}
onSelect={(item) =>
select({ type, id: item.id, title: item.name }, item.isOpen)
}
/>
) : items.length > 0 ? (
items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => select({ type, id: item.id, title: item.name }, item.isOpen)}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))
) : (
EMPTY_SUBMENU
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
})
) : (
<div className='px-[8px] py-[5px] text-center font-medium text-[12px] text-[var(--text-tertiary)]'>
No results
</div>
)
})
)}
) : (
<>
{available.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
{type === 'workflow' ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
) : (
<Icon className='h-[14px] w-[14px]' />
)}
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() =>
select({ type, id: item.id, title: item.name }, item.isOpen)
}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,4 +1,11 @@
export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown'
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'
export { ResourceActions, ResourceContent } from './resource-content'
export type { ResourceTypeConfig } from './resource-registry'
export {
getResourceConfig,
invalidateResourceQueries,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'
export { ResourceTabs } from './resource-tabs'

View File

@@ -4,7 +4,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'
import { Square } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import { BookOpen, FileX, SquareArrowUpRight, WorkflowX } from '@/components/emcn/icons'
import { FileX, SquareArrowUpRight, WorkflowX } from '@/components/emcn/icons'
import {
markRunToolManuallyStopped,
reportManualRunToolStop,
@@ -13,6 +13,10 @@ import {
FileViewer,
type PreviewMode,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -158,14 +162,14 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
<Button
variant='subtle'
onClick={handleOpenWorkflow}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open workflow'
>
<SquareArrowUpRight className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open Workflow</p>
<p>Open workflow</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
@@ -174,13 +178,13 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
variant='subtle'
onClick={() => void handleRun()}
disabled={isRunButtonDisabled}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label={isExecuting ? 'Stop workflow' : 'Run workflow'}
>
{isExecuting ? (
<Square className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<Square className={RESOURCE_TAB_ICON_CLASS} />
) : (
<PlayOutline className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<PlayOutline className={RESOURCE_TAB_ICON_CLASS} />
)}
</Button>
</Tooltip.Trigger>
@@ -213,14 +217,14 @@ export function EmbeddedKnowledgeBaseActions({
<Button
variant='subtle'
onClick={handleOpenKnowledgeBase}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Open knowledge base'
>
<BookOpen className='h-[16px] w-[16px] text-[var(--text-icon)]' />
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<p>Open Knowledge Base</p>
<p>Open knowledge base</p>
</Tooltip.Content>
</Tooltip.Root>
)
@@ -243,12 +247,10 @@ function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
if (!workflowExists || hasLoadError) {
return (
<div className='flex h-full flex-col items-center justify-center gap-[12px]'>
<WorkflowX className='h-[32px] w-[32px] text-[var(--text-muted)]' />
<WorkflowX className='h-[32px] w-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-[4px]'>
<h2 className='font-medium text-[20px] text-[var(--text-secondary)]'>
Workflow not found
</h2>
<p className='text-[13px] text-[var(--text-muted)]'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>Workflow not found</h2>
<p className='text-[13px] text-[var(--text-body)]'>
This workflow may have been deleted or moved
</p>
</div>
@@ -278,10 +280,10 @@ function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
if (!file) {
return (
<div className='flex h-full flex-col items-center justify-center gap-[12px]'>
<FileX className='h-[32px] w-[32px] text-[var(--text-muted)]' />
<FileX className='h-[32px] w-[32px] text-[var(--text-icon)]' />
<div className='flex flex-col items-center gap-[4px]'>
<h2 className='font-medium text-[20px] text-[var(--text-secondary)]'>File not found</h2>
<p className='text-[13px] text-[var(--text-muted)]'>
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>File not found</h2>
<p className='text-[13px] text-[var(--text-body)]'>
This file may have been deleted or moved
</p>
</div>

View File

@@ -0,0 +1,7 @@
export type { ResourceTypeConfig } from './resource-registry'
export {
getResourceConfig,
invalidateResourceQueries,
RESOURCE_REGISTRY,
RESOURCE_TYPES,
} from './resource-registry'

View File

@@ -119,10 +119,6 @@ export function getResourceConfig(type: MothershipResourceType): ResourceTypeCon
return RESOURCE_REGISTRY[type]
}
// ---------------------------------------------------------------------------
// Resource query invalidation
// ---------------------------------------------------------------------------
const RESOURCE_INVALIDATORS: Record<
MothershipResourceType,
(qc: QueryClient, workspaceId: string, resourceId: string) => void

View File

@@ -0,0 +1,6 @@
export const RESOURCE_TAB_GAP_CLASS = 'gap-[6px]'
export const RESOURCE_TAB_ICON_BUTTON_CLASS =
'shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
export const RESOURCE_TAB_ICON_CLASS = 'h-[16px] w-[16px] text-[var(--text-icon)]'

View File

@@ -1,6 +1,6 @@
import {
type ComponentProps,
type ReactNode,
type SVGProps,
useCallback,
useEffect,
useMemo,
@@ -8,11 +8,16 @@ import {
useState,
} from 'react'
import { Button, Tooltip } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { Columns3, Eye, PanelLeft, Rows3 } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_GAP_CLASS,
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls'
import type {
MothershipResource,
MothershipResourceType,
@@ -27,38 +32,15 @@ import {
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const LEFT_HALF =
'M10.25 0.75H3.25C1.86929 0.75 0.75 1.86929 0.75 3.25V16.25C0.75 17.6307 1.86929 18.75 3.25 18.75H10.25V0.75Z'
const RIGHT_HALF =
'M10.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H10.25V0.75Z'
const OUTLINE =
'M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z'
function PreviewModeIcon({ mode, ...props }: { mode: PreviewMode } & SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
{mode !== 'preview' && <path d={LEFT_HALF} fill='var(--surface-active)' stroke='none' />}
{mode !== 'editor' && <path d={RIGHT_HALF} fill='var(--surface-active)' stroke='none' />}
<path d={OUTLINE} />
<path d='M10.25 0.75V18.75' />
</svg>
)
}
const EDGE_ZONE = 40
const SCROLL_SPEED = 8
const PREVIEW_MODE_ICONS = {
editor: Rows3,
split: Columns3,
preview: Eye,
} satisfies Record<PreviewMode, (props: ComponentProps<typeof Eye>) => ReactNode>
/**
* Builds a `type:id` -> current name lookup from live query data so resource
* tabs always reflect the latest name even after a rename.
@@ -108,6 +90,7 @@ export function ResourceTabs({
onCyclePreviewMode,
actions,
}: ResourceTabsProps) {
const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split']
const nameLookup = useResourceNameLookup(workspaceId)
const scrollNodeRef = useRef<HTMLDivElement>(null)
@@ -177,36 +160,6 @@ export function ResourceTabs({
[resources]
)
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
const midpoint = rect.left + rect.width / 2
const gap = e.clientX < midpoint ? idx : idx + 1
setDropGapIdx(gap)
const container = scrollNodeRef.current
if (!container) return
const cRect = container.getBoundingClientRect()
const x = e.clientX
if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
if (x < cRect.left + EDGE_ZONE) {
const tick = () => {
container.scrollLeft -= SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else if (x > cRect.right - EDGE_ZONE) {
const tick = () => {
container.scrollLeft += SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else {
autoScrollRaf.current = null
}
}, [])
const stopAutoScroll = useCallback(() => {
if (autoScrollRaf.current) {
cancelAnimationFrame(autoScrollRaf.current)
@@ -214,6 +167,44 @@ export function ResourceTabs({
}
}, [])
const startEdgeScroll = useCallback(
(clientX: number) => {
const container = scrollNodeRef.current
if (!container) return
const cRect = container.getBoundingClientRect()
if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
if (clientX < cRect.left + EDGE_ZONE) {
const tick = () => {
container.scrollLeft -= SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else if (clientX > cRect.right - EDGE_ZONE) {
const tick = () => {
container.scrollLeft += SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else {
stopAutoScroll()
}
},
[stopAutoScroll]
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
const midpoint = rect.left + rect.width / 2
const gap = e.clientX < midpoint ? idx : idx + 1
setDropGapIdx(gap)
startEdgeScroll(e.clientX)
},
[startEdgeScroll]
)
const handleDragLeave = useCallback(() => {
setDropGapIdx(null)
stopAutoScroll()
@@ -261,16 +252,21 @@ export function ResourceTabs({
}, [stopAutoScroll])
return (
<div className='flex shrink-0 items-center border-[var(--border)] border-b px-[16px] py-[8.5px]'>
<div
className={cn(
'flex shrink-0 items-center border-[var(--border)] border-b px-[16px] py-[8.5px]',
RESOURCE_TAB_GAP_CLASS
)}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='subtle'
onClick={onCollapse}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Collapse resource view'
>
<PanelLeft className='-scale-x-100 h-[16px] w-[16px] text-[var(--text-icon)]' />
<PanelLeft className={cn(RESOURCE_TAB_ICON_CLASS, '-scale-x-100')} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
@@ -279,29 +275,13 @@ export function ResourceTabs({
</Tooltip.Root>
<div
ref={scrollNodeRef}
className='mx-[2px] flex min-w-0 items-center gap-[6px] overflow-x-auto px-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
className={cn(
'flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
RESOURCE_TAB_GAP_CLASS
)}
onDragOver={(e) => {
e.preventDefault()
const container = scrollNodeRef.current
if (!container) return
const cRect = container.getBoundingClientRect()
const x = e.clientX
if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
if (x < cRect.left + EDGE_ZONE) {
const tick = () => {
container.scrollLeft -= SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else if (x > cRect.right - EDGE_ZONE) {
const tick = () => {
container.scrollLeft += SCROLL_SPEED
autoScrollRaf.current = requestAnimationFrame(tick)
}
autoScrollRaf.current = requestAnimationFrame(tick)
} else {
stopAutoScroll()
}
startEdgeScroll(e.clientX)
}}
onDrop={handleDrop}
>
@@ -366,7 +346,7 @@ export function ResourceTabs({
aria-label={`Close ${displayName}`}
>
<svg
className='h-[10px] w-[10px] text-[var(--text-tertiary)]'
className='h-[10px] w-[10px] text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
@@ -390,17 +370,17 @@ export function ResourceTabs({
</div>
)
})}
{chatId && (
<AddResourceDropdown
workspaceId={workspaceId}
existingKeys={existingKeys}
onAdd={handleAdd}
onSwitch={onSelect}
/>
)}
</div>
{chatId && (
<AddResourceDropdown
workspaceId={workspaceId}
existingKeys={existingKeys}
onAdd={handleAdd}
onSwitch={onSelect}
/>
)}
{(actions || (previewMode && onCyclePreviewMode)) && (
<div className='ml-auto flex shrink-0 items-center gap-[6px]'>
<div className={cn('ml-auto flex shrink-0 items-center', RESOURCE_TAB_GAP_CLASS)}>
{actions}
{previewMode && onCyclePreviewMode && (
<Tooltip.Root>
@@ -408,13 +388,10 @@ export function ResourceTabs({
<Button
variant='subtle'
onClick={onCyclePreviewMode}
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
aria-label='Cycle preview mode'
>
<PreviewModeIcon
mode={previewMode}
className='h-[16px] w-[16px] text-[var(--text-icon)]'
/>
<PreviewModeIcon mode={previewMode} className={RESOURCE_TAB_ICON_CLASS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>

View File

@@ -91,11 +91,11 @@ export function MothershipView({
/>
) : (
<div className='flex h-full flex-col items-center justify-center gap-[4px] px-[24px]'>
<h2 className='font-semibold text-[20px] text-[var(--text-secondary)]'>
<h2 className='font-semibold text-[20px] text-[var(--text-primary)]'>
No resources open
</h2>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Click the <span className='font-medium text-[var(--text-secondary)]'>+</span> button
<p className='text-[12px] text-[var(--text-body)]'>
Click the <span className='font-medium text-[var(--text-primary)]'>+</span> button
above to add a resource to this task
</p>
</div>

View File

@@ -61,38 +61,36 @@ interface TemplatePromptsProps {
export function TemplatePrompts({ onSelect }: TemplatePromptsProps) {
return (
<div>
<div className='grid grid-cols-3 gap-[16px]'>
{TEMPLATES.map((template) => {
const Icon = template.icon
return (
<button
key={template.title}
type='button'
onClick={() => onSelect(template.prompt)}
className='group flex cursor-pointer flex-col text-left'
>
<div className='overflow-hidden rounded-[10px] border border-[var(--border-1)]'>
<div className='relative h-[120px] w-full overflow-hidden'>
<Image
src={template.image}
alt={template.title}
fill
unoptimized
className='object-cover transition-transform duration-300 group-hover:scale-105'
/>
</div>
<div className='flex items-center gap-[6px] border-[var(--border-1)] border-t bg-[var(--white)] px-[10px] py-[6px] dark:bg-[var(--surface-4)]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-base text-[14px] text-[var(--text-body)]'>
{template.title}
</span>
</div>
<div className='grid grid-cols-3 gap-[16px]'>
{TEMPLATES.map((template) => {
const Icon = template.icon
return (
<button
key={template.title}
type='button'
onClick={() => onSelect(template.prompt)}
className='group flex cursor-pointer flex-col text-left'
>
<div className='overflow-hidden rounded-[10px] border border-[var(--border-1)]'>
<div className='relative h-[120px] w-full overflow-hidden'>
<Image
src={template.image}
alt={template.title}
fill
unoptimized
className='object-cover transition-transform duration-300 group-hover:scale-105'
/>
</div>
</button>
)
})}
</div>
<div className='flex items-center gap-[6px] border-[var(--border-1)] border-t bg-[var(--white)] px-[10px] py-[6px] dark:bg-[var(--surface-4)]'>
<Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-base text-[14px] text-[var(--text-body)]'>
{template.title}
</span>
</div>
</div>
</button>
)
})}
</div>
)
}

View File

@@ -1,89 +0,0 @@
'use client'
import { X } from 'lucide-react'
import { Badge } from '@/components/emcn'
import { Database, File as FileIcon, Table as TableIcon } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import type { ChatContext } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ContextPillsProps {
contexts: ChatContext[]
onRemoveContext: (context: ChatContext) => void
}
function WorkflowPillIcon({ workflowId, className }: { workflowId: string; className?: string }) {
const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888')
return (
<div
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
style={{
backgroundColor: color,
borderColor: `${color}60`,
backgroundClip: 'padding-box',
}}
/>
)
}
function getContextIcon(ctx: ChatContext) {
switch (ctx.kind) {
case 'workflow':
case 'current_workflow':
return <WorkflowPillIcon workflowId={ctx.workflowId} className='mr-[4px] h-[10px] w-[10px]' />
case 'workflow_block':
return <WorkflowPillIcon workflowId={ctx.workflowId} className='mr-[4px] h-[10px] w-[10px]' />
case 'knowledge':
return <Database className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'templates':
return <WorkflowIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'past_chat':
return null
case 'logs':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'blocks':
return <TableIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'table':
return <TableIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'file':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
case 'docs':
return <FileIcon className='mr-[4px] h-[10px] w-[10px] text-[var(--text-icon)]' />
default:
return null
}
}
export function ContextPills({ contexts, onRemoveContext }: ContextPillsProps) {
const visibleContexts = contexts.filter((c) => c.kind !== 'current_workflow')
if (visibleContexts.length === 0) {
return null
}
return (
<>
{visibleContexts.map((ctx, idx) => (
<Badge
key={`selctx-${idx}-${ctx.label}`}
variant='outline'
className='inline-flex items-center gap-1 rounded-[6px] px-2 py-[4.5px] text-xs leading-[12px]'
title={ctx.label}
>
{getContextIcon(ctx)}
<span className='max-w-[140px] truncate leading-[12px]'>{ctx.label}</span>
<button
type='button'
onClick={() => onRemoveContext(ctx)}
className='text-muted-foreground transition-colors hover:text-foreground'
title='Remove context'
aria-label='Remove context'
>
<X className='h-3 w-3' strokeWidth={1.75} />
</button>
</Badge>
))}
</>
)
}

View File

@@ -1 +0,0 @@
export { ContextPills } from './context-pills'

View File

@@ -0,0 +1 @@
export { UserMessageContent } from './user-message-content'

View File

@@ -1,7 +1,12 @@
'use client'
import { Database, Table as TableIcon } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { ChatMessageContext } from '../types'
const USER_MESSAGE_CLASSES =
'whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
interface UserMessageContentProps {
content: string
@@ -39,7 +44,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
return ranges
}
function MentionHighlight({ context, token }: { context: ChatMessageContext; token: string }) {
function MentionHighlight({ context }: { context: ChatMessageContext }) {
const workflowColor = useWorkflowRegistry((state) => {
if (context.kind === 'workflow' || context.kind === 'current_workflow') {
return state.workflows[context.workflowId || '']?.color ?? null
@@ -47,32 +52,53 @@ function MentionHighlight({ context, token }: { context: ChatMessageContext; tok
return null
})
const bgColor = workflowColor ? `${workflowColor}40` : 'rgba(50, 189, 126, 0.4)'
let icon: React.ReactNode = null
const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'
switch (context.kind) {
case 'workflow':
case 'current_workflow':
icon = workflowColor ? (
<span
className='inline-block h-[12px] w-[12px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflowColor,
borderColor: `${workflowColor}60`,
backgroundClip: 'padding-box',
}}
/>
) : null
break
case 'knowledge':
icon = <Database className={iconClasses} />
break
case 'table':
icon = <TableIcon className={iconClasses} />
break
case 'file': {
const FileDocIcon = getDocumentIcon('', context.label)
icon = <FileDocIcon className={iconClasses} />
break
}
}
return (
<span className='rounded-[4px] px-[2px] py-[1px]' style={{ backgroundColor: bgColor }}>
{token}
<span className='inline-flex items-baseline gap-[4px] rounded-[5px] bg-[var(--surface-5)] px-[5px]'>
{icon && <span className='relative top-[2px] flex-shrink-0'>{icon}</span>}
{context.label}
</span>
)
}
export function UserMessageContent({ content, contexts }: UserMessageContentProps) {
if (!contexts || contexts.length === 0) {
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{content}
</p>
)
return <p className={USER_MESSAGE_CLASSES}>{content}</p>
}
const ranges = computeMentionRanges(content, contexts)
if (ranges.length === 0) {
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{content}
</p>
)
return <p className={USER_MESSAGE_CLASSES}>{content}</p>
}
const elements: React.ReactNode[] = []
@@ -86,13 +112,7 @@ export function UserMessageContent({ content, contexts }: UserMessageContentProp
elements.push(<span key={`text-${i}-${lastIndex}`}>{before}</span>)
}
elements.push(
<MentionHighlight
key={`mention-${i}-${range.start}`}
context={range.context}
token={range.token}
/>
)
elements.push(<MentionHighlight key={`mention-${i}-${range.start}`} context={range.context} />)
lastIndex = range.end
}
@@ -101,9 +121,5 @@ export function UserMessageContent({ content, contexts }: UserMessageContentProp
elements.push(<span key={`tail-${lastIndex}`}>{tail}</span>)
}
return (
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{elements}
</p>
)
return <p className={USER_MESSAGE_CLASSES}>{elements}</p>
}

View File

@@ -23,33 +23,13 @@ import {
UserInput,
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import type { FileAttachmentForApi } from './components/user-input/user-input'
import { useAutoScroll, useChat } from './hooks'
import type { MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
const THINKING_BLOCKS = [
{ color: '#2ABBF8', delay: '0s' },
{ color: '#00F701', delay: '0.2s' },
{ color: '#FA4EDF', delay: '0.6s' },
{ color: '#FFCC02', delay: '0.4s' },
] as const
function ThinkingIndicator() {
return (
<div className='grid h-[16px] w-[16px] grid-cols-2 gap-[1.5px]'>
{THINKING_BLOCKS.map((block, i) => (
<div
key={i}
className='animate-thinking-block rounded-[2px]'
style={{ backgroundColor: block.color, animationDelay: block.delay }}
/>
))}
</div>
)
}
interface FileAttachmentPillProps {
mediaType: string
filename: string
@@ -59,8 +39,8 @@ function FileAttachmentPill({ mediaType, filename }: FileAttachmentPillProps) {
const Icon = getDocumentIcon(mediaType, filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-[10px] bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
<span className='truncate text-[11px] text-[var(--text-secondary)]'>{filename}</span>
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[11px] text-[var(--text-body)]'>{filename}</span>
</div>
)
}
@@ -217,8 +197,6 @@ export function Home({ chatId }: HomeProps = {}) {
wasSendingRef.current = isSending
}, [isSending, resolvedChatId, markRead])
const visibleResources = resources
useEffect(() => {
if (!isResourceAnimatingIn) return
const timer = setTimeout(() => setIsResourceAnimatingIn(false), 400)
@@ -360,7 +338,7 @@ export function Home({ chatId }: HomeProps = {}) {
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-4'
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
@@ -405,12 +383,7 @@ export function Home({ chatId }: HomeProps = {}) {
const isThisStreaming = isSending && isLastAssistant
if (!hasBlocks && !msg.content && isThisStreaming) {
return (
<div key={msg.id} className='flex items-center gap-[8px] py-[8px]'>
<ThinkingIndicator />
<span className='font-base text-[14px] text-[var(--text-body)]'>Thinking</span>
</div>
)
return <PendingTagIndicator key={msg.id} />
}
if (!hasBlocks && !msg.content) return null
@@ -448,7 +421,7 @@ export function Home({ chatId }: HomeProps = {}) {
<MothershipView
workspaceId={workspaceId}
chatId={resolvedChatId}
resources={visibleResources}
resources={resources}
activeResourceId={activeResourceId}
onSelectResource={setActiveResourceId}
onAddResource={addResource}

View File

@@ -1,20 +1,26 @@
import { useCallback, useEffect, useRef } from 'react'
const BOTTOM_THRESHOLD = 30
/** Tolerance for keeping stickiness during programmatic auto-scroll. */
const STICK_THRESHOLD = 30
/** User must scroll back to within this distance to re-engage auto-scroll. */
const REATTACH_THRESHOLD = 5
/**
* Manages sticky auto-scroll for a streaming chat container.
*
* Stays pinned to the bottom while content streams in. Detaches when the user
* scrolls beyond {@link BOTTOM_THRESHOLD} from the bottom. Re-attaches when
* the scroll position returns within the threshold. Preserves bottom position
* across container resizes (e.g. sidebar collapse).
* Stays pinned to the bottom while content streams in. Detaches immediately
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
* bottom to re-engage.
*/
export function useAutoScroll(isStreaming: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
const atBottomRef = useRef(true)
const stickyRef = useRef(true)
const userDetachedRef = useRef(false)
const prevScrollTopRef = useRef(0)
const prevScrollHeightRef = useRef(0)
const touchStartYRef = useRef(0)
const rafIdRef = useRef(0)
const teardownRef = useRef<(() => void) | null>(null)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
@@ -23,30 +29,8 @@ export function useAutoScroll(isStreaming: boolean) {
}, [])
const callbackRef = useCallback((el: HTMLDivElement | null) => {
teardownRef.current?.()
teardownRef.current = null
containerRef.current = el
if (!el) return
el.scrollTop = el.scrollHeight
atBottomRef.current = true
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
atBottomRef.current = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
}
const ro = new ResizeObserver(() => {
if (atBottomRef.current) el.scrollTop = el.scrollHeight
})
el.addEventListener('scroll', onScroll, { passive: true })
ro.observe(el)
teardownRef.current = () => {
el.removeEventListener('scroll', onScroll)
ro.disconnect()
}
if (el) el.scrollTop = el.scrollHeight
}, [])
useEffect(() => {
@@ -54,26 +38,75 @@ export function useAutoScroll(isStreaming: boolean) {
const el = containerRef.current
if (!el) return
atBottomRef.current = true
stickyRef.current = true
userDetachedRef.current = false
prevScrollTopRef.current = el.scrollTop
prevScrollHeightRef.current = el.scrollHeight
scrollToBottom()
const detach = () => {
stickyRef.current = false
userDetachedRef.current = true
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) detach()
}
const onTouchStart = (e: TouchEvent) => {
touchStartYRef.current = e.touches[0].clientY
}
const onTouchMove = (e: TouchEvent) => {
if (e.touches[0].clientY > touchStartYRef.current) detach()
}
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD
if (distanceFromBottom <= threshold) {
stickyRef.current = true
userDetachedRef.current = false
} else if (
scrollTop < prevScrollTopRef.current &&
scrollHeight <= prevScrollHeightRef.current
) {
stickyRef.current = false
}
prevScrollTopRef.current = scrollTop
prevScrollHeightRef.current = scrollHeight
}
const guardedScroll = () => {
if (atBottomRef.current) scrollToBottom()
if (stickyRef.current) scrollToBottom()
}
const onMutation = () => {
if (!atBottomRef.current) return
prevScrollHeightRef.current = el.scrollHeight
if (!stickyRef.current) return
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = requestAnimationFrame(guardedScroll)
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('scroll', onScroll)
observer.disconnect()
cancelAnimationFrame(rafIdRef.current)
if (atBottomRef.current) scrollToBottom()
if (stickyRef.current) scrollToBottom()
}
}, [isStreaming, scrollToBottom])

View File

@@ -11,6 +11,7 @@ import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
type TaskChatHistory,
type TaskStoredContentBlock,
@@ -27,7 +28,6 @@ import { useFolderStore } from '@/stores/folders/store'
import type { ChatContext } from '@/stores/panel'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { invalidateResourceQueries } from '../components/mothership-view/components/resource-registry'
import type { FileAttachmentForApi } from '../components/user-input/user-input'
import type {
ChatMessage,

View File

@@ -104,6 +104,13 @@ export type ToolPhase =
export type ToolCallStatus = 'executing' | 'success' | 'error' | 'cancelled'
export interface ToolCallData {
id: string
toolName: string
displayTitle: string
status: ToolCallStatus
}
export interface ToolCallInfo {
id: string
name: string

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, ExternalLink, Eye, Pencil, Plus, Trash } from '@/components/emcn/icons'
import { Copy, Eye, Pencil, Plus, SquareArrowUpRight, Trash } from '@/components/emcn/icons'
interface ChunkContextMenuProps {
isOpen: boolean
@@ -98,7 +98,7 @@ export function ChunkContextMenu({
<>
{hasNavigationSection && (
<DropdownMenuItem onSelect={onOpenInNewTab!}>
<ExternalLink />
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { ExternalLink, Eye, Pencil, Plus, TagIcon, Trash } from '@/components/emcn/icons'
import { Eye, Pencil, Plus, SquareArrowUpRight, TagIcon, Trash } from '@/components/emcn/icons'
interface DocumentContextMenuProps {
isOpen: boolean
@@ -100,13 +100,13 @@ export function DocumentContextMenu({
<>
{!isMultiSelect && onOpenInNewTab && (
<DropdownMenuItem onSelect={onOpenInNewTab}>
<ExternalLink />
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}
{!isMultiSelect && onOpenSource && (
<DropdownMenuItem onSelect={onOpenSource}>
<ExternalLink />
<SquareArrowUpRight />
Open source
</DropdownMenuItem>
)}

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, ExternalLink, Pencil, TagIcon, Trash } from '@/components/emcn/icons'
import { Copy, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@/components/emcn/icons'
interface KnowledgeBaseContextMenuProps {
isOpen: boolean
@@ -75,7 +75,7 @@ export function KnowledgeBaseContextMenu({
>
{hasNavigationSection && (
<DropdownMenuItem onSelect={onOpenInNewTab!}>
<ExternalLink />
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, ExternalLink, Eye, ListFilter, X } from '@/components/emcn/icons'
import { Copy, Eye, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
@@ -74,7 +74,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>
<ExternalLink />
<SquareArrowUpRight />
Open Workflow
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onOpenPreview}>

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, ExternalLink } from '@/components/emcn/icons'
import { Copy, SquareArrowUpRight } from '@/components/emcn/icons'
interface NavItemContextMenuProps {
isOpen: boolean
@@ -52,7 +52,7 @@ export function NavItemContextMenu({
onClose()
}}
>
<ExternalLink />
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
<DropdownMenuItem

View File

@@ -15,13 +15,13 @@ import {
import {
Check,
Duplicate,
ExternalLink,
FolderPlus,
Lock,
LogOut,
Palette,
Pencil,
Plus,
SquareArrowUpRight,
Trash,
Unlock,
Upload,
@@ -384,7 +384,7 @@ export function ContextMenu({
onClose()
}}
>
<ExternalLink />
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}

View File

@@ -22,7 +22,7 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { Check, ChevronRight, Circle, Search } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
const ANIMATION_CLASSES =
@@ -59,14 +59,14 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
'flex min-w-0 cursor-default select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
inset && 'pl-[28px]',
className
)}
{...props}
>
{children}
<ChevronRight className='ml-auto' />
<ChevronRight className='ml-auto shrink-0' />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
@@ -79,7 +79,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
ANIMATION_CLASSES,
'z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[6px] text-[var(--text-body)] shadow-sm',
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[6px] text-[var(--text-body)] shadow-sm',
className
)}
{...props}
@@ -97,7 +97,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
ANIMATION_CLASSES,
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[6px] text-[var(--text-body)] shadow-sm',
'z-50 max-h-[240px] min-w-[8rem] max-w-[220px] origin-[--radix-dropdown-menu-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[6px] text-[var(--text-body)] shadow-sm',
className
)}
{...props}
@@ -115,7 +115,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
'relative flex min-w-0 cursor-pointer select-none items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)] outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
inset && 'pl-[28px]',
className
)}
@@ -199,6 +199,47 @@ const DropdownMenuSeparator = React.forwardRef<
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const stopPropagation = (e: React.KeyboardEvent) => e.stopPropagation()
const DropdownMenuSearchInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, onKeyDown, ...props }, ref) => {
const internalRef = React.useRef<HTMLInputElement | null>(null)
React.useEffect(() => {
internalRef.current?.focus()
}, [])
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
internalRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref]
)
return (
<div className='mx-[2px] mt-[2px] mb-[2px] flex shrink-0 items-center gap-[8px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] dark:bg-[var(--surface-4)]'>
<Search className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<input
ref={setRefs}
onKeyDown={(e) => {
stopPropagation(e)
onKeyDown?.(e)
}}
className={cn(
'w-full bg-transparent py-[4px] font-medium text-[12px] text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:outline-none',
className
)}
{...props}
/>
</div>
)
})
DropdownMenuSearchInput.displayName = 'DropdownMenuSearchInput'
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
@@ -218,6 +259,7 @@ export {
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSearchInput,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,

View File

@@ -47,6 +47,7 @@ export {
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSearchInput,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,

View File

@@ -1,26 +0,0 @@
import type { SVGProps } from 'react'
/**
* ExternalLink icon component - arrow pointing out of a box
* @param props - SVG properties including className, fill, etc.
*/
export function ExternalLink(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M11.75 0.75H19.25V8.25' />
<path d='M19.25 0.75L9.25 10.75' />
<path d='M16.25 11.75V16.25C16.25 17.6307 15.1307 18.75 13.75 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V5.75C0.75 4.36929 1.86929 3.25 3.25 3.25H7.75' />
</svg>
)
}

View File

@@ -25,7 +25,6 @@ export { DocumentAttachment } from './document-attachment'
export { Download } from './download'
export { Duplicate } from './duplicate'
export { Expand } from './expand'
export { ExternalLink } from './external-link'
export { Eye } from './eye'
export { File } from './file'
export { FileX } from './file-x'

View File

@@ -40,7 +40,7 @@ export function PlayOutline(props: SVGProps<SVGSVGElement>) {
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M6 2L18 10L6 18Z' />
<path d='M6.25 3.9C6.25 3.408 6.799 3.114 7.209 3.399L15.709 9.299C16.063 9.545 16.063 10.069 15.709 10.315L7.209 16.215C6.799 16.5 6.25 16.206 6.25 15.714V3.9Z' />
</svg>
)
}

View File

@@ -1,7 +1,7 @@
import type { SVGProps } from 'react'
/**
* SquareArrowUpRight icon — a rounded square with an arrow exiting the top-right corner.
* SquareArrowUpRight icon — a rounded square with an arrow pointing top-right inside it.
* @param props - SVG properties including className, fill, etc.
*/
export function SquareArrowUpRight(props: SVGProps<SVGSVGElement>) {
@@ -18,9 +18,9 @@ export function SquareArrowUpRight(props: SVGProps<SVGSVGElement>) {
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M13.5 1.5H19V7' />
<path d='M19 1.5L10.25 10.25' />
<path d='M16.5 11.5V16.5C16.5 17.6046 15.6046 18.5 14.5 18.5H4C2.89543 18.5 2 17.6046 2 16.5V6C2 4.89543 2.89543 4 4 4H9' />
<rect x='1.25' y='0.75' width='18' height='18' rx='2.5' />
<path d='M9.75 5.25H14.25V9.75' />
<path d='M14.25 5.25L6.25 14.25' />
</svg>
)
}

View File

@@ -95,6 +95,7 @@ export async function resolveOrCreateChat(params: {
}
}
const now = new Date()
const [newChat] = await db
.insert(copilotChats)
.values({
@@ -105,6 +106,7 @@ export async function resolveOrCreateChat(params: {
title: null,
model,
messages: [],
lastSeenAt: now,
})
.returning()