mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvements: ui/ux around mothership
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { ResourceTypeConfig } from './resource-registry'
|
||||
export {
|
||||
getResourceConfig,
|
||||
invalidateResourceQueries,
|
||||
RESOURCE_REGISTRY,
|
||||
RESOURCE_TYPES,
|
||||
} from './resource-registry'
|
||||
@@ -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
|
||||
@@ -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)]'
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ContextPills } from './context-pills'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { UserMessageContent } from './user-message-content'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSearchInput,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user