mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement: chat
This commit is contained in:
@@ -54,11 +54,14 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
|
||||
return resolvePreviewType(file.type, file.name) !== null
|
||||
}
|
||||
|
||||
export type PreviewMode = 'editor' | 'split' | 'preview'
|
||||
|
||||
interface FileViewerProps {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
canEdit: boolean
|
||||
showPreview?: boolean
|
||||
previewMode?: PreviewMode
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
@@ -70,6 +73,7 @@ export function FileViewer({
|
||||
workspaceId,
|
||||
canEdit,
|
||||
showPreview,
|
||||
previewMode,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
@@ -83,7 +87,7 @@ export function FileViewer({
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={canEdit}
|
||||
showPreview={showPreview}
|
||||
previewMode={previewMode ?? (showPreview ? 'split' : 'editor')}
|
||||
autoFocus={autoFocus}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
@@ -103,7 +107,7 @@ interface TextEditorProps {
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
canEdit: boolean
|
||||
showPreview?: boolean
|
||||
previewMode: PreviewMode
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
@@ -114,7 +118,7 @@ function TextEditor({
|
||||
file,
|
||||
workspaceId,
|
||||
canEdit,
|
||||
showPreview,
|
||||
previewMode,
|
||||
autoFocus,
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
@@ -256,36 +260,43 @@ function TextEditor({
|
||||
)
|
||||
}
|
||||
|
||||
const showEditor = previewMode !== 'preview'
|
||||
const showPreviewPane = previewMode !== 'editor'
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='relative flex flex-1 overflow-hidden'>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
readOnly={!canEdit}
|
||||
spellCheck={false}
|
||||
style={showPreview ? { width: `${splitPct}%`, flexShrink: 0 } : undefined}
|
||||
className={cn(
|
||||
'h-full resize-none border-0 bg-transparent p-[24px] font-mono text-[14px] text-[var(--text-body)] outline-none placeholder:text-[var(--text-subtle)]',
|
||||
!showPreview && 'w-full',
|
||||
isResizing && 'pointer-events-none'
|
||||
)}
|
||||
/>
|
||||
{showPreview && (
|
||||
{showEditor && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
readOnly={!canEdit}
|
||||
spellCheck={false}
|
||||
style={showPreviewPane ? { width: `${splitPct}%`, flexShrink: 0 } : undefined}
|
||||
className={cn(
|
||||
'h-full resize-none border-0 bg-transparent p-[24px] font-mono text-[14px] text-[var(--text-body)] outline-none placeholder:text-[var(--text-subtle)]',
|
||||
!showPreviewPane && 'w-full',
|
||||
isResizing && 'pointer-events-none'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showPreviewPane && (
|
||||
<>
|
||||
<div className='relative shrink-0'>
|
||||
<div className='h-full w-px bg-[var(--border)]' />
|
||||
<div
|
||||
className='-left-[3px] absolute top-0 z-10 h-full w-[6px] cursor-col-resize'
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize split'
|
||||
/>
|
||||
{isResizing && (
|
||||
<div className='-translate-x-[0.5px] pointer-events-none absolute top-0 z-20 h-full w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
</div>
|
||||
{showEditor && (
|
||||
<div className='relative shrink-0'>
|
||||
<div className='h-full w-px bg-[var(--border)]' />
|
||||
<div
|
||||
className='-left-[3px] absolute top-0 z-10 h-full w-[6px] cursor-col-resize'
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize split'
|
||||
/>
|
||||
{isResizing && (
|
||||
<div className='-translate-x-[0.5px] pointer-events-none absolute top-0 z-20 h-full w-[2px] bg-[var(--selection)]' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
|
||||
>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export type { PreviewMode } from './file-viewer'
|
||||
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { createElement } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowRight } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
|
||||
export interface OptionsItemData {
|
||||
title: string
|
||||
@@ -177,6 +178,8 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
|
||||
const entries = Object.entries(data)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
const disabled = !onSelect
|
||||
|
||||
return (
|
||||
<div className='animate-stream-fade-in'>
|
||||
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
|
||||
@@ -190,9 +193,11 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
|
||||
<button
|
||||
key={key}
|
||||
type='button'
|
||||
onClick={() => onSelect?.(key)}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect?.(title)}
|
||||
className={cn(
|
||||
'flex items-center gap-[8px] border-[var(--divider)] px-[8px] py-[8px] text-left transition-colors hover:bg-[var(--surface-5)]',
|
||||
'flex items-center gap-[8px] border-[var(--divider)] px-[8px] py-[8px] text-left transition-colors',
|
||||
disabled ? 'cursor-not-allowed' : 'hover:bg-[var(--surface-5)]',
|
||||
i > 0 && 'border-t'
|
||||
)}
|
||||
>
|
||||
@@ -213,7 +218,44 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function getCredentialIcon(provider: string): React.ComponentType<{ className?: string }> | null {
|
||||
const lower = provider.toLowerCase()
|
||||
|
||||
const directMatch = OAUTH_PROVIDERS[lower]
|
||||
if (directMatch) return directMatch.icon
|
||||
|
||||
for (const config of Object.values(OAUTH_PROVIDERS)) {
|
||||
if (config.name.toLowerCase() === lower) return config.icon
|
||||
for (const service of Object.values(config.services)) {
|
||||
if (service.name.toLowerCase() === lower) return service.icon
|
||||
if (service.providerId.toLowerCase() === lower) return service.icon
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const LockIcon = (props: { className?: string }) => (
|
||||
<svg
|
||||
className={props.className}
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='2' y='5' width='12' height='8' rx='1.5' stroke='currentColor' strokeWidth='1.3' />
|
||||
<path
|
||||
d='M5 5V3.5a3 3 0 1 1 6 0V5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.3'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<circle cx='8' cy='9.5' r='1.25' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function CredentialDisplay({ data }: { data: CredentialTagData }) {
|
||||
const Icon = getCredentialIcon(data.provider) ?? LockIcon
|
||||
|
||||
return (
|
||||
<a
|
||||
href={data.link}
|
||||
@@ -221,25 +263,11 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
|
||||
rel='noopener noreferrer'
|
||||
className='flex animate-stream-fade-in items-center gap-[8px] rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover:bg-[var(--surface-5)]'
|
||||
>
|
||||
<svg
|
||||
className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='2' y='5' width='12' height='8' rx='1.5' stroke='currentColor' strokeWidth='1.3' />
|
||||
<path
|
||||
d='M5 5V3.5a3 3 0 1 1 6 0V5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.3'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
<circle cx='8' cy='9.5' r='1.25' fill='currentColor' />
|
||||
</svg>
|
||||
{createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
|
||||
<span className='flex-1 font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
|
||||
Connect {data.provider}
|
||||
</span>
|
||||
<ArrowUpRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
|
||||
<ArrowRight className='h-[16px] w-[16px] shrink-0 text-[var(--text-icon)]' />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -279,7 +307,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
|
||||
className='mt-2 inline-flex items-center gap-1 font-[500] text-[13px] text-amber-700 underline decoration-dashed underline-offset-2 transition-colors hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-200'
|
||||
>
|
||||
{buttonLabel}
|
||||
<ArrowUpRight className='h-3 w-3' />
|
||||
<ArrowRight className='h-3 w-3' />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -202,7 +202,12 @@ export function MessageContent({
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<ChatContent key={`text-${i}`} content={segment.content} isStreaming={isStreaming} />
|
||||
<ChatContent
|
||||
key={`text-${i}`}
|
||||
content={segment.content}
|
||||
isStreaming={isStreaming}
|
||||
onOptionSelect={onOptionSelect}
|
||||
/>
|
||||
)
|
||||
case 'agent_group': {
|
||||
const allToolsDone =
|
||||
|
||||
@@ -3,21 +3,23 @@ import {
|
||||
Asterisk,
|
||||
Blimp,
|
||||
BubbleChatPreview,
|
||||
Bug,
|
||||
Calendar,
|
||||
ClipboardList,
|
||||
Connections,
|
||||
Database,
|
||||
Eye,
|
||||
File,
|
||||
FolderCode,
|
||||
Key,
|
||||
Hammer,
|
||||
Integration,
|
||||
Library,
|
||||
ListFilter,
|
||||
Loader,
|
||||
Pencil,
|
||||
Play,
|
||||
Rocket,
|
||||
Search,
|
||||
Settings,
|
||||
TerminalWindow,
|
||||
Wrench,
|
||||
} from '@/components/emcn'
|
||||
import { Table as TableIcon } from '@/components/emcn/icons'
|
||||
import type { MothershipToolName, SubagentName } from '../../types'
|
||||
@@ -42,18 +44,18 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
|
||||
workspace_file: File,
|
||||
create_workflow: Connections,
|
||||
edit_workflow: Pencil,
|
||||
build: Connections,
|
||||
build: Hammer,
|
||||
run: Play,
|
||||
deploy: Rocket,
|
||||
auth: Key,
|
||||
auth: Integration,
|
||||
knowledge: Database,
|
||||
table: TableIcon,
|
||||
job: Loader,
|
||||
job: Calendar,
|
||||
agent: BubbleChatPreview,
|
||||
custom_tool: Settings,
|
||||
custom_tool: Wrench,
|
||||
research: Search,
|
||||
plan: ListFilter,
|
||||
debug: Eye,
|
||||
plan: ClipboardList,
|
||||
debug: Bug,
|
||||
edit: Pencil,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lazy, Suspense, useMemo } from 'react'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import {
|
||||
FileViewer,
|
||||
isPreviewable,
|
||||
type PreviewMode,
|
||||
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
|
||||
@@ -23,6 +23,7 @@ const LOADING_SKELETON = (
|
||||
interface ResourceContentProps {
|
||||
workspaceId: string
|
||||
resource: MothershipResource
|
||||
previewMode?: PreviewMode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,13 +31,20 @@ interface ResourceContentProps {
|
||||
* Handles table, file, and workflow resource types with appropriate
|
||||
* embedded rendering for each.
|
||||
*/
|
||||
export function ResourceContent({ workspaceId, resource }: ResourceContentProps) {
|
||||
export function ResourceContent({ workspaceId, resource, previewMode }: ResourceContentProps) {
|
||||
switch (resource.type) {
|
||||
case 'table':
|
||||
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
|
||||
|
||||
case 'file':
|
||||
return <EmbeddedFile key={resource.id} workspaceId={workspaceId} fileId={resource.id} />
|
||||
return (
|
||||
<EmbeddedFile
|
||||
key={resource.id}
|
||||
workspaceId={workspaceId}
|
||||
fileId={resource.id}
|
||||
previewMode={previewMode}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'workflow':
|
||||
return (
|
||||
@@ -53,9 +61,10 @@ export function ResourceContent({ workspaceId, resource }: ResourceContentProps)
|
||||
interface EmbeddedFileProps {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
previewMode?: PreviewMode
|
||||
}
|
||||
|
||||
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
|
||||
function EmbeddedFile({ workspaceId, fileId, previewMode }: EmbeddedFileProps) {
|
||||
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
|
||||
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
|
||||
|
||||
@@ -76,7 +85,7 @@ function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
|
||||
file={file}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={true}
|
||||
showPreview={isPreviewable(file)}
|
||||
previewMode={previewMode}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import type { ElementType } from 'react'
|
||||
import type { ElementType, SVGProps } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { PanelLeft, Table as TableIcon } from '@/components/emcn/icons'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type {
|
||||
MothershipResource,
|
||||
MothershipResourceType,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
interface ResourceTabsProps {
|
||||
resources: MothershipResource[]
|
||||
activeId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onCollapse: () => void
|
||||
previewMode?: PreviewMode
|
||||
onCyclePreviewMode?: () => void
|
||||
}
|
||||
|
||||
const RESOURCE_ICONS: Record<Exclude<MothershipResourceType, 'file'>, ElementType> = {
|
||||
@@ -34,36 +66,58 @@ function getResourceIcon(resource: MothershipResource): ElementType {
|
||||
* Horizontal tab bar for switching between mothership resources.
|
||||
* Renders each resource as a subtle Button matching ResourceHeader actions.
|
||||
*/
|
||||
export function ResourceTabs({ resources, activeId, onSelect, onCollapse }: ResourceTabsProps) {
|
||||
export function ResourceTabs({
|
||||
resources,
|
||||
activeId,
|
||||
onSelect,
|
||||
onCollapse,
|
||||
previewMode,
|
||||
onCyclePreviewMode,
|
||||
}: ResourceTabsProps) {
|
||||
return (
|
||||
<div className='flex shrink-0 items-center gap-[6px] overflow-x-auto border-[var(--border)] border-b px-[16px] py-[8.5px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
<div className='flex shrink-0 items-center border-[var(--border)] border-b px-[16px] py-[8.5px]'>
|
||||
<Button
|
||||
variant='subtle'
|
||||
onClick={onCollapse}
|
||||
className='shrink-0 bg-transparent px-[8px] py-[4px] text-[12px]'
|
||||
className='shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
|
||||
aria-label='Collapse resource view'
|
||||
>
|
||||
<PanelLeft className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
<PanelLeft className='-scale-x-100 h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
</Button>
|
||||
{resources.map((resource) => {
|
||||
const Icon = getResourceIcon(resource)
|
||||
const isActive = activeId === resource.id
|
||||
<div className='flex min-w-0 items-center gap-[6px] overflow-x-auto pl-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{resources.map((resource) => {
|
||||
const Icon = getResourceIcon(resource)
|
||||
const isActive = activeId === resource.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={resource.id}
|
||||
variant='subtle'
|
||||
onClick={() => onSelect(resource.id)}
|
||||
className={cn(
|
||||
'shrink-0 bg-transparent px-[8px] py-[4px] text-[12px]',
|
||||
isActive && 'bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
|
||||
{resource.title}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<Button
|
||||
key={resource.id}
|
||||
variant='subtle'
|
||||
onClick={() => onSelect(resource.id)}
|
||||
className={cn(
|
||||
'shrink-0 bg-transparent px-[8px] py-[4px] text-[12px]',
|
||||
isActive && 'bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />
|
||||
{resource.title}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{previewMode && onCyclePreviewMode && (
|
||||
<Button
|
||||
variant='subtle'
|
||||
onClick={onCyclePreviewMode}
|
||||
className='ml-auto shrink-0 bg-transparent px-[8px] py-[5px] text-[12px]'
|
||||
aria-label='Cycle preview mode'
|
||||
>
|
||||
<PreviewModeIcon
|
||||
mode={previewMode}
|
||||
className='h-[16px] w-[16px] text-[var(--text-icon)]'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { ResourceContent, ResourceTabs } from './components'
|
||||
|
||||
const PREVIEWABLE_EXTENSIONS = new Set(['md', 'html', 'htm', 'csv'])
|
||||
const PREVIEW_ONLY_EXTENSIONS = new Set(['html', 'htm'])
|
||||
|
||||
const PREVIEW_CYCLE: Record<PreviewMode, PreviewMode> = {
|
||||
editor: 'split',
|
||||
split: 'preview',
|
||||
preview: 'editor',
|
||||
} as const
|
||||
|
||||
interface MothershipViewProps {
|
||||
workspaceId: string
|
||||
resources: MothershipResource[]
|
||||
@@ -30,6 +42,17 @@ export function MothershipView({
|
||||
}: MothershipViewProps) {
|
||||
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
|
||||
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('split')
|
||||
const handleCyclePreview = useCallback(() => setPreviewMode((m) => PREVIEW_CYCLE[m]), [])
|
||||
|
||||
useEffect(() => {
|
||||
const ext = active?.type === 'file' ? getFileExtension(active.title) : ''
|
||||
setPreviewMode(PREVIEW_ONLY_EXTENSIONS.has(ext) ? 'preview' : 'split')
|
||||
}, [active?.id])
|
||||
|
||||
const isActivePreviewable =
|
||||
active?.type === 'file' && PREVIEWABLE_EXTENSIONS.has(getFileExtension(active.title))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -38,15 +61,23 @@ export function MothershipView({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-[400px] flex-1 flex-col'>
|
||||
<div className='flex min-h-0 min-w-[400px] flex-1 flex-col'>
|
||||
<ResourceTabs
|
||||
resources={resources}
|
||||
activeId={active?.id ?? null}
|
||||
onSelect={onSelectResource}
|
||||
onCollapse={onCollapse}
|
||||
previewMode={isActivePreviewable ? previewMode : undefined}
|
||||
onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined}
|
||||
/>
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
{active && <ResourceContent workspaceId={workspaceId} resource={active} />}
|
||||
{active && (
|
||||
<ResourceContent
|
||||
workspaceId={workspaceId}
|
||||
resource={active}
|
||||
previewMode={isActivePreviewable ? previewMode : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,11 @@ import { persistImportedWorkflow } from '@/lib/workflows/operations/import-expor
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { MessageContent, MothershipView, UserInput } from './components'
|
||||
import type { FileAttachmentForApi } from './components/user-input/user-input'
|
||||
import { useChat } from './hooks'
|
||||
import { useAutoScroll, useChat } from './hooks'
|
||||
|
||||
const logger = createLogger('Home')
|
||||
|
||||
const RESOURCE_PANEL_EXPAND_DELAY = 160
|
||||
const RESOURCE_PANEL_EXPAND_DELAY = 175
|
||||
|
||||
const THINKING_BLOCKS = [
|
||||
{ color: '#2ABBF8', delay: '0s' },
|
||||
@@ -130,7 +130,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
isSending,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
chatBottomRef,
|
||||
resources,
|
||||
activeResourceId,
|
||||
setActiveResourceId,
|
||||
@@ -171,13 +170,16 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const scrollContainerRef = useAutoScroll(isSending)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
|
||||
if (!hasMessages) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center bg-[var(--bg)] px-[24px]'>
|
||||
<h1 className='mb-[24px] font-[450] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
|
||||
What do you want to do?
|
||||
What should we get done{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}
|
||||
?
|
||||
</h1>
|
||||
<UserInput
|
||||
defaultValue={initialPrompt}
|
||||
@@ -193,7 +195,10 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
return (
|
||||
<div className='relative flex h-full bg-[var(--bg)]'>
|
||||
<div className='flex h-full min-w-0 flex-1 flex-col'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 py-4'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 py-4'
|
||||
>
|
||||
<div className='mx-auto max-w-[42rem] space-y-6'>
|
||||
{messages.map((msg, index) => {
|
||||
if (msg.role === 'user') {
|
||||
@@ -255,17 +260,19 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
if (!hasBlocks && !msg.content) return null
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
|
||||
return (
|
||||
<div key={msg.id} className='pb-4'>
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? sendMessage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={chatBottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useAnimatedPlaceholder } from './use-animated-placeholder'
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export type { UseChatReturn } from './use-chat'
|
||||
export { useChat } from './use-chat'
|
||||
export { useStreamingReveal } from './use-streaming-reveal'
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const BOTTOM_THRESHOLD = 30
|
||||
|
||||
/**
|
||||
* Manages sticky auto-scroll for a streaming chat container.
|
||||
*
|
||||
* Stays pinned to the bottom while content streams in. Detaches when the user
|
||||
* explicitly scrolls up (wheel, touch, or scrollbar drag). Re-attaches when
|
||||
* the scroll position returns to within {@link BOTTOM_THRESHOLD} of the bottom.
|
||||
*/
|
||||
export function useAutoScroll(isStreaming: boolean) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const stickyRef = useRef(true)
|
||||
const prevScrollTopRef = useRef(0)
|
||||
const prevScrollHeightRef = useRef(0)
|
||||
const touchStartYRef = useRef(0)
|
||||
const rafIdRef = useRef(0)
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
const callbackRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
stickyRef.current = true
|
||||
prevScrollTopRef.current = el.scrollTop
|
||||
prevScrollHeightRef.current = el.scrollHeight
|
||||
scrollToBottom()
|
||||
|
||||
const detach = () => {
|
||||
stickyRef.current = false
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (distanceFromBottom <= BOTTOM_THRESHOLD) {
|
||||
stickyRef.current = true
|
||||
} else if (
|
||||
scrollTop < prevScrollTopRef.current &&
|
||||
scrollHeight <= prevScrollHeightRef.current
|
||||
) {
|
||||
stickyRef.current = false
|
||||
}
|
||||
|
||||
prevScrollTopRef.current = scrollTop
|
||||
prevScrollHeightRef.current = scrollHeight
|
||||
}
|
||||
|
||||
const guardedScroll = () => {
|
||||
if (stickyRef.current) scrollToBottom()
|
||||
}
|
||||
|
||||
const onMutation = () => {
|
||||
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)
|
||||
}
|
||||
}, [isStreaming, scrollToBottom])
|
||||
|
||||
return callbackRef
|
||||
}
|
||||
@@ -41,7 +41,6 @@ export interface UseChatReturn {
|
||||
error: string | null
|
||||
sendMessage: (message: string, fileAttachments?: FileAttachmentForApi[]) => Promise<void>
|
||||
stopGeneration: () => Promise<void>
|
||||
chatBottomRef: React.RefObject<HTMLDivElement | null>
|
||||
resources: MothershipResource[]
|
||||
activeResourceId: string | null
|
||||
setActiveResourceId: (id: string | null) => void
|
||||
@@ -149,7 +148,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const chatIdRef = useRef<string | undefined>(initialChatId)
|
||||
const chatBottomRef = useRef<HTMLDivElement>(null)
|
||||
const appliedChatIdRef = useRef<string | undefined>(undefined)
|
||||
const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null)
|
||||
const streamIdRef = useRef<string | undefined>(undefined)
|
||||
@@ -545,7 +543,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
sendingRef.current = false
|
||||
setIsSending(false)
|
||||
abortControllerRef.current = null
|
||||
chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
invalidateChatQueries()
|
||||
}, [invalidateChatQueries])
|
||||
|
||||
@@ -723,7 +720,6 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
chatBottomRef,
|
||||
resources,
|
||||
activeResourceId,
|
||||
setActiveResourceId,
|
||||
|
||||
@@ -138,17 +138,17 @@ export interface ChatMessage {
|
||||
}
|
||||
|
||||
export const SUBAGENT_LABELS: Record<SubagentName, string> = {
|
||||
build: 'Building',
|
||||
deploy: 'Deploying',
|
||||
auth: 'Connecting credentials',
|
||||
research: 'Researching',
|
||||
knowledge: 'Managing knowledge base',
|
||||
table: 'Managing tables',
|
||||
custom_tool: 'Creating tool',
|
||||
superagent: 'Executing action',
|
||||
plan: 'Planning',
|
||||
debug: 'Debugging',
|
||||
edit: 'Editing workflow',
|
||||
build: 'Build agent',
|
||||
deploy: 'Deploy agent',
|
||||
auth: 'Integration agent',
|
||||
research: 'Research agent',
|
||||
knowledge: 'Knowledge agent',
|
||||
table: 'Table agent',
|
||||
custom_tool: 'Custom Tool agent',
|
||||
superagent: 'Superagent',
|
||||
plan: 'Plan agent',
|
||||
debug: 'Debug agent',
|
||||
edit: 'Edit agent',
|
||||
} as const
|
||||
|
||||
export interface ToolUIMetadata {
|
||||
|
||||
28
apps/sim/components/emcn/icons/clipboard-list.tsx
Normal file
28
apps/sim/components/emcn/icons/clipboard-list.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* ClipboardList icon component - clipboard with checklist lines
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function ClipboardList(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='M3.75 4.25C3.75 2.86929 4.86929 1.75 6.25 1.75H14.25C15.6307 1.75 16.75 2.86929 16.75 4.25V17.25C16.75 18.6307 15.6307 19.75 14.25 19.75H6.25C4.86929 19.75 3.75 18.6307 3.75 17.25V4.25Z' />
|
||||
<path d='M7.75 0.75H12.75V3.25C12.75 3.80228 12.3023 4.25 11.75 4.25H8.75C8.19772 4.25 7.75 3.80228 7.75 3.25V0.75Z' />
|
||||
<path d='M7.75 8.75H12.75' />
|
||||
<path d='M7.75 11.75H12.75' />
|
||||
<path d='M7.75 14.75H10.75' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
25
apps/sim/components/emcn/icons/hammer.tsx
Normal file
25
apps/sim/components/emcn/icons/hammer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Hammer icon component - build/construction tool
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Hammer(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='M7 6.75L10.25 3.5L16.75 10L13.5 13.25Z' />
|
||||
<path d='M10.25 10L3.25 17' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { Bug } from './bug'
|
||||
export { Calendar } from './calendar'
|
||||
export { Card } from './card'
|
||||
export { ChevronDown } from './chevron-down'
|
||||
export { ClipboardList } from './clipboard-list'
|
||||
export { Columns3 } from './columns3'
|
||||
export { Connections } from './connections'
|
||||
export { Copy } from './copy'
|
||||
@@ -27,10 +28,12 @@ export { File } from './file'
|
||||
export { Fingerprint } from './fingerprint'
|
||||
export { FolderCode } from './folder-code'
|
||||
export { FolderPlus } from './folder-plus'
|
||||
export { Hammer } from './hammer'
|
||||
export { Hand } from './hand'
|
||||
export { HelpCircle } from './help-circle'
|
||||
export { HexSimple } from './hex-simple'
|
||||
export { Home } from './home'
|
||||
export { Integration } from './integration'
|
||||
export { Key } from './key'
|
||||
export { KeySquare } from './key-square'
|
||||
export { Layout } from './layout'
|
||||
|
||||
26
apps/sim/components/emcn/icons/integration.tsx
Normal file
26
apps/sim/components/emcn/icons/integration.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* Integration icon component - two connected blocks
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function Integration(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}
|
||||
>
|
||||
<rect x='1' y='6.5' width='7' height='7' rx='1.5' />
|
||||
<rect x='14' y='6.5' width='7' height='7' rx='1.5' />
|
||||
<path d='M8 10H14' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user