improvement: chat

This commit is contained in:
Emir Karabeg
2026-03-11 02:35:36 -07:00
parent 00eb812365
commit c8098d38e3
17 changed files with 443 additions and 112 deletions

View File

@@ -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')}
>

View File

@@ -1 +1,2 @@
export type { PreviewMode } from './file-viewer'
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'

View File

@@ -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>
)

View File

@@ -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 =

View File

@@ -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,
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 {

View 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>
)
}

View 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>
)
}

View File

@@ -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'

View 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>
)
}