mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
v0.6.46: mothership queueing, web vitals
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
interface DotGridProps {
|
||||
className?: string
|
||||
cols: number
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { type MotionValue, motion, useScroll, useTransform } from 'framer-motion'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { FeaturesPreview } from '@/app/(landing)/components/features/components/features-preview'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16)
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16)
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
const MAX_HEIGHT = 120
|
||||
|
||||
const CTA_BUTTON =
|
||||
|
||||
@@ -38,7 +38,7 @@ const BLOCK_LINKS: FooterItem[] = [
|
||||
{ label: 'Router', href: 'https://docs.sim.ai/blocks/router', external: true },
|
||||
{ label: 'Function', href: 'https://docs.sim.ai/blocks/function', external: true },
|
||||
{ label: 'Condition', href: 'https://docs.sim.ai/blocks/condition', external: true },
|
||||
{ label: 'API', href: 'https://docs.sim.ai/blocks/api', external: true },
|
||||
{ label: 'API Block', href: 'https://docs.sim.ai/blocks/api', external: true },
|
||||
{ label: 'Workflow', href: 'https://docs.sim.ai/blocks/workflow', external: true },
|
||||
{ label: 'Parallel', href: 'https://docs.sim.ai/blocks/parallel', external: true },
|
||||
{ label: 'Guardrails', href: 'https://docs.sim.ai/blocks/guardrails', external: true },
|
||||
@@ -194,7 +194,7 @@ export default function Footer({ hideCTA }: FooterProps) {
|
||||
<Link href='/' aria-label='Sim home'>
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
alt=''
|
||||
width={85}
|
||||
height={26}
|
||||
className='h-[26.4px] w-auto'
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
const DemoRequestModal = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/demo-request/demo-request-modal').then(
|
||||
(m) => m.DemoRequestModal
|
||||
),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
const LandingPreview = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/landing-preview/landing-preview').then(
|
||||
|
||||
@@ -334,6 +334,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
type='button'
|
||||
onClick={handleSubmit}
|
||||
disabled={isEmpty}
|
||||
aria-label='Submit message'
|
||||
className='flex h-[28px] w-[28px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||
style={{
|
||||
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Blimp, BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||
import { AgentIcon, HubspotIcon, OpenAIIcon, SalesforceIcon } from '@/components/icons'
|
||||
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import {
|
||||
EASE_OUT,
|
||||
type EditorPromptData,
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
type PanelTab = 'copilot' | 'editor'
|
||||
|
||||
const EDITOR_BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { GithubOutlineIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { SessionContext } from '@/app/_shell/providers/session-provider'
|
||||
import {
|
||||
BlogDropdown,
|
||||
type NavBlogPost,
|
||||
@@ -17,6 +17,11 @@ import { GitHubStars } from '@/app/(landing)/components/navbar/components/github
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
type DropdownId = 'docs' | 'blog' | null
|
||||
|
||||
interface NavLink {
|
||||
@@ -48,7 +53,9 @@ interface NavbarProps {
|
||||
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
|
||||
const brand = getBrandConfig()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const sessionCtx = useContext(SessionContext)
|
||||
const session = sessionCtx?.data ?? null
|
||||
const isSessionPending = sessionCtx?.isPending ?? true
|
||||
const isAuthenticated = Boolean(session?.user?.id)
|
||||
const isBrowsingHome = searchParams.has('home')
|
||||
const useHomeLinks = isAuthenticated || isBrowsingHome
|
||||
@@ -125,7 +132,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
|
||||
) : (
|
||||
<Image
|
||||
src='/logo/sim-landing.svg'
|
||||
alt='Sim'
|
||||
alt=''
|
||||
width={71}
|
||||
height={22}
|
||||
className='h-[22px] w-auto'
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { AuthModal } from '@/app/(landing)/components/auth-modal/auth-modal'
|
||||
import { DemoRequestModal } from '@/app/(landing)/components/demo-request/demo-request-modal'
|
||||
import { trackLandingCta } from '@/app/(landing)/landing-analytics'
|
||||
|
||||
const AuthModal = dynamic(
|
||||
() => import('@/app/(landing)/components/auth-modal/auth-modal').then((m) => m.AuthModal),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
const DemoRequestModal = dynamic(
|
||||
() =>
|
||||
import('@/app/(landing)/components/demo-request/demo-request-modal').then(
|
||||
(m) => m.DemoRequestModal
|
||||
),
|
||||
{ loading: () => null }
|
||||
)
|
||||
|
||||
interface PricingTier {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -470,7 +470,7 @@ export default function Templates() {
|
||||
aria-labelledby={`template-tab-${activeIndex}`}
|
||||
className='relative hidden flex-1 lg:block'
|
||||
>
|
||||
<div aria-hidden='true' className='h-full'>
|
||||
<div aria-hidden='true' inert className='h-full'>
|
||||
<LandingPreviewWorkflow
|
||||
key={activeIndex}
|
||||
workflow={activeWorkflow}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
capture_performance: false,
|
||||
capture_dead_clicks: false,
|
||||
enable_heatmaps: false,
|
||||
disable_session_recording: true,
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: {
|
||||
|
||||
@@ -92,6 +92,13 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
if (
|
||||
typeof posthog.startSessionRecording === 'function' &&
|
||||
typeof posthog.sessionRecordingStarted === 'function' &&
|
||||
!posthog.sessionRecordingStarted()
|
||||
) {
|
||||
posthog.startSessionRecording()
|
||||
}
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content'
|
||||
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
|
||||
import {
|
||||
UserInput,
|
||||
type UserInputHandle,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/user-input'
|
||||
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -36,14 +39,12 @@ interface MothershipChatProps {
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemoveQueuedMessage: (id: string) => void
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
onEditQueuedMessage: (id: string) => QueuedMessage | undefined
|
||||
userId?: string
|
||||
chatId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
onContextRemove?: (context: ChatContext) => void
|
||||
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
layout?: 'mothership-view' | 'copilot-view'
|
||||
initialScrollBlocked?: boolean
|
||||
animateInput?: boolean
|
||||
@@ -91,8 +92,6 @@ export function MothershipChat({
|
||||
onContextAdd,
|
||||
onContextRemove,
|
||||
onWorkspaceResourceSelect,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
layout = 'mothership-view',
|
||||
initialScrollBlocked = false,
|
||||
animateInput = false,
|
||||
@@ -106,11 +105,24 @@ export function MothershipChat({
|
||||
})
|
||||
const hasMessages = messages.length > 0
|
||||
const initialScrollDoneRef = useRef(false)
|
||||
const userInputRef = useRef<UserInputHandle>(null)
|
||||
const handleSendQueuedHead = useCallback(() => {
|
||||
const topMessage = messageQueue[0]
|
||||
if (!topMessage) return
|
||||
void onSendQueuedMessage(topMessage.id)
|
||||
}, [messageQueue, onSendQueuedMessage])
|
||||
const handleEditQueued = useCallback(
|
||||
(id: string) => {
|
||||
const msg = onEditQueuedMessage(id)
|
||||
if (msg) userInputRef.current?.loadQueuedMessage(msg)
|
||||
},
|
||||
[onEditQueuedMessage]
|
||||
)
|
||||
const handleEditQueuedTail = useCallback(() => {
|
||||
const tail = messageQueue[messageQueue.length - 1]
|
||||
if (!tail) return
|
||||
handleEditQueued(tail.id)
|
||||
}, [messageQueue, handleEditQueued])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages) {
|
||||
@@ -205,9 +217,10 @@ export function MothershipChat({
|
||||
messageQueue={messageQueue}
|
||||
onRemove={onRemoveQueuedMessage}
|
||||
onSendNow={onSendQueuedMessage}
|
||||
onEdit={onEditQueuedMessage}
|
||||
onEdit={handleEditQueued}
|
||||
/>
|
||||
<UserInput
|
||||
ref={userInputRef}
|
||||
onSubmit={onSubmit}
|
||||
isSending={isStreamActive}
|
||||
onStopGeneration={onStopGeneration}
|
||||
@@ -215,9 +228,8 @@ export function MothershipChat({
|
||||
userId={userId}
|
||||
onContextAdd={onContextAdd}
|
||||
onContextRemove={onContextRemove}
|
||||
editValue={editValue}
|
||||
onEditValueConsumed={onEditValueConsumed}
|
||||
onSendQueuedHead={handleSendQueuedHead}
|
||||
onEditQueuedTail={handleEditQueuedTail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
|
||||
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
const NARROW_WIDTH_PX = 320
|
||||
|
||||
interface QueuedMessagesProps {
|
||||
messageQueue: QueuedMessage[]
|
||||
onRemove: (id: string) => void
|
||||
@@ -14,11 +17,29 @@ interface QueuedMessagesProps {
|
||||
|
||||
export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: QueuedMessagesProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isNarrow, setIsNarrow] = useState(false)
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
roRef.current.disconnect()
|
||||
roRef.current = null
|
||||
}
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
setIsNarrow(entries[0].contentRect.width < NARROW_WIDTH_PX)
|
||||
})
|
||||
ro.observe(el)
|
||||
roRef.current = ro
|
||||
}, [])
|
||||
|
||||
if (messageQueue.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='-mb-3 mx-3.5 overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-3)] pb-3'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='-mb-3 mx-3.5 overflow-hidden rounded-t-[16px] border border-[var(--border-1)] border-b-0 bg-[var(--surface-3)] pb-3'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
@@ -39,16 +60,41 @@ export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: Qu
|
||||
{messageQueue.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className='flex items-center gap-2 px-3.5 py-1.5 transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
className='flex items-center gap-2 py-1.5 pr-2 pl-3.5 transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] shrink-0 items-center justify-center'>
|
||||
<div className='h-[10px] w-[10px] rounded-full border-[1.5px] border-[color-mix(in_srgb,var(--text-tertiary)_40%,transparent)]' />
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[var(--text-primary)] text-small'>{msg.content}</p>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>
|
||||
<UserMessageContent
|
||||
content={msg.content}
|
||||
contexts={msg.contexts}
|
||||
plainMentions
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{msg.fileAttachments && msg.fileAttachments.length > 0 && (
|
||||
<span className='inline-flex min-w-0 max-w-[40%] shrink items-center gap-1 rounded-[5px] bg-[var(--surface-5)] px-[5px] py-0.5 text-[var(--text-primary)] text-small'>
|
||||
<Paperclip className='h-[12px] w-[12px] shrink-0 text-[var(--text-icon)]' />
|
||||
{isNarrow ? (
|
||||
<span className='shrink-0 text-[var(--text-secondary)]'>
|
||||
{msg.fileAttachments.length}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className='truncate'>{msg.fileAttachments[0].filename}</span>
|
||||
{msg.fileAttachments.length > 1 && (
|
||||
<span className='shrink-0 text-[var(--text-secondary)]'>
|
||||
+{msg.fileAttachments.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className='flex shrink-0 items-center gap-0.5'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { UserInput } from './user-input'
|
||||
export { UserInput, type UserInputHandle } from './user-input'
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
@@ -26,6 +35,7 @@ import {
|
||||
import type {
|
||||
FileAttachmentForApi,
|
||||
MothershipResource,
|
||||
QueuedMessage,
|
||||
} from '@/app/workspace/[workspaceId]/home/types'
|
||||
import {
|
||||
useContextManagement,
|
||||
@@ -91,8 +101,6 @@ function getCaretAnchor(
|
||||
|
||||
interface UserInputProps {
|
||||
defaultValue?: string
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
onSubmit: (
|
||||
text: string,
|
||||
fileAttachments?: FileAttachmentForApi[],
|
||||
@@ -105,21 +113,28 @@ interface UserInputProps {
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
onContextRemove?: (context: ChatContext) => void
|
||||
onSendQueuedHead?: () => void
|
||||
onEditQueuedTail?: () => void
|
||||
}
|
||||
|
||||
export function UserInput({
|
||||
defaultValue = '',
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
onSubmit,
|
||||
isSending,
|
||||
onStopGeneration,
|
||||
isInitialView = true,
|
||||
userId,
|
||||
onContextAdd,
|
||||
onContextRemove,
|
||||
onSendQueuedHead,
|
||||
}: UserInputProps) {
|
||||
export interface UserInputHandle {
|
||||
loadQueuedMessage: (msg: QueuedMessage) => void
|
||||
}
|
||||
|
||||
export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function UserInput(
|
||||
{
|
||||
defaultValue = '',
|
||||
onSubmit,
|
||||
isSending,
|
||||
onStopGeneration,
|
||||
isInitialView = true,
|
||||
userId,
|
||||
onContextAdd,
|
||||
onContextRemove,
|
||||
onSendQueuedHead,
|
||||
onEditQueuedTail,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
|
||||
@@ -136,18 +151,6 @@ export function UserInput({
|
||||
setPrevDefaultValue(defaultValue)
|
||||
}
|
||||
|
||||
const [prevEditValue, setPrevEditValue] = useState(editValue)
|
||||
if (editValue && editValue !== prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
setValue(editValue)
|
||||
} else if (!editValue && prevEditValue) {
|
||||
setPrevEditValue(editValue)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editValue) onEditValueConsumed?.()
|
||||
}, [editValue, onEditValueConsumed])
|
||||
|
||||
const files = useFileAttachments({
|
||||
userId: userId || session?.user?.id,
|
||||
workspaceId,
|
||||
@@ -269,6 +272,8 @@ export function UserInput({
|
||||
contextRef.current = contextManagement
|
||||
const onSendQueuedHeadRef = useRef(onSendQueuedHead)
|
||||
onSendQueuedHeadRef.current = onSendQueuedHead
|
||||
const onEditQueuedTailRef = useRef(onEditQueuedTail)
|
||||
onEditQueuedTailRef.current = onEditQueuedTail
|
||||
const isSendingRef = useRef(isSending)
|
||||
isSendingRef.current = isSending
|
||||
|
||||
@@ -277,6 +282,34 @@ export function UserInput({
|
||||
const atInsertPosRef = useRef<number | null>(null)
|
||||
const pendingCursorRef = useRef<number | null>(null)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
loadQueuedMessage: (msg: QueuedMessage) => {
|
||||
setValue(msg.content)
|
||||
const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.filename,
|
||||
size: a.size,
|
||||
type: a.media_type,
|
||||
path: a.path ?? '',
|
||||
key: a.key,
|
||||
uploading: false,
|
||||
}))
|
||||
files.restoreAttachedFiles(restored)
|
||||
contextManagement.setSelectedContexts(msg.contexts ?? [])
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
const end = textarea.value.length
|
||||
textarea.setSelectionRange(end, end)
|
||||
})
|
||||
},
|
||||
}),
|
||||
[files.restoreAttachedFiles, contextManagement.setSelectedContexts, textareaRef]
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
@@ -430,6 +463,7 @@ export function UserInput({
|
||||
filename: f.name,
|
||||
media_type: f.type,
|
||||
size: f.size,
|
||||
...(f.path ? { path: f.path } : {}),
|
||||
}))
|
||||
|
||||
onSubmit(
|
||||
@@ -452,6 +486,15 @@ export function UserInput({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const isEmpty = valueRef.current.length === 0 && filesRef.current.attachedFiles.length === 0
|
||||
if (isEmpty && onEditQueuedTailRef.current) {
|
||||
e.preventDefault()
|
||||
onEditQueuedTailRef.current()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
const hasSubmitPayload =
|
||||
@@ -763,4 +806,4 @@ export function UserInput({
|
||||
{files.isDragging && <DropOverlay />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
|
||||
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
@@ -9,9 +10,17 @@ import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
const USER_MESSAGE_CLASSES =
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-[430] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
|
||||
|
||||
const COMPACT_CLASSES =
|
||||
'truncate text-small leading-[20px] font-[430] font-[family-name:var(--font-inter)] text-[var(--text-primary)] tracking-[0] antialiased'
|
||||
|
||||
interface UserMessageContentProps {
|
||||
content: string
|
||||
contexts?: ChatMessageContext[]
|
||||
className?: string
|
||||
/** When true, render mentions as plain inline text (no icon/pill) so truncation flows naturally. */
|
||||
plainMentions?: boolean
|
||||
/** Use compact single-line layout with truncation. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
@@ -64,17 +73,23 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function UserMessageContent({ content, contexts }: UserMessageContentProps) {
|
||||
export function UserMessageContent({
|
||||
content,
|
||||
contexts,
|
||||
className,
|
||||
plainMentions = false,
|
||||
compact = false,
|
||||
}: UserMessageContentProps) {
|
||||
const trimmed = content.trim()
|
||||
const classes = cn(compact ? COMPACT_CLASSES : USER_MESSAGE_CLASSES, className)
|
||||
|
||||
if (!contexts || contexts.length === 0) {
|
||||
return <p className={USER_MESSAGE_CLASSES}>{trimmed}</p>
|
||||
}
|
||||
|
||||
const ranges = computeMentionRanges(content, contexts)
|
||||
const ranges = useMemo(
|
||||
() => (contexts && contexts.length > 0 ? computeMentionRanges(content, contexts) : []),
|
||||
[content, contexts]
|
||||
)
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return <p className={USER_MESSAGE_CLASSES}>{trimmed}</p>
|
||||
return <p className={classes}>{trimmed}</p>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
@@ -88,7 +103,20 @@ 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} />)
|
||||
if (plainMentions) {
|
||||
elements.push(
|
||||
<span
|
||||
key={`mention-${i}-${range.start}`}
|
||||
className='font-medium text-[var(--text-primary)]'
|
||||
>
|
||||
{content.slice(range.start, range.end)}
|
||||
</span>
|
||||
)
|
||||
} else {
|
||||
elements.push(
|
||||
<MentionHighlight key={`mention-${i}-${range.start}`} context={range.context} />
|
||||
)
|
||||
}
|
||||
lastIndex = range.end
|
||||
}
|
||||
|
||||
@@ -97,5 +125,5 @@ export function UserMessageContent({ content, contexts }: UserMessageContentProp
|
||||
elements.push(<span key={`tail-${lastIndex}`}>{tail}</span>)
|
||||
}
|
||||
|
||||
return <p className={USER_MESSAGE_CLASSES}>{elements}</p>
|
||||
return <p className={classes}>{elements}</p>
|
||||
}
|
||||
|
||||
@@ -159,26 +159,6 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
})
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [prevChatId, setPrevChatId] = useState(chatId)
|
||||
const clearEditingValue = useCallback(() => setEditingInputValue(''), [])
|
||||
|
||||
// Clear editing value when navigating to a different chat (guarded render-phase update)
|
||||
if (chatId !== prevChatId) {
|
||||
setPrevChatId(chatId)
|
||||
setEditingInputValue('')
|
||||
}
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const msg = editQueuedMessage(id)
|
||||
if (msg) {
|
||||
setEditingInputValue(msg.content)
|
||||
}
|
||||
},
|
||||
[editQueuedMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (activeResourceId) {
|
||||
@@ -375,13 +355,11 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
messageQueue={messageQueue}
|
||||
onRemoveQueuedMessage={removeFromQueue}
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
onEditQueuedMessage={editQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
onWorkspaceResourceSelect={handleWorkspaceResourceSelect}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
animateInput={isInputEntering}
|
||||
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
|
||||
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface FileAttachmentForApi {
|
||||
filename: string
|
||||
media_type: string
|
||||
size: number
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface QueuedMessage {
|
||||
|
||||
@@ -301,6 +301,19 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
setAttachedFiles([])
|
||||
}, [attachedFiles])
|
||||
|
||||
/**
|
||||
* Replaces the current attached files with a given set.
|
||||
* Cleans up preview URLs from the prior set before replacing.
|
||||
*/
|
||||
const restoreAttachedFiles = useCallback((files: AttachedFile[]) => {
|
||||
setAttachedFiles((prev) => {
|
||||
prev.forEach((f) => {
|
||||
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||||
})
|
||||
return files
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
attachedFiles,
|
||||
@@ -321,6 +334,7 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
clearAttachedFiles,
|
||||
restoreAttachedFiles,
|
||||
processFiles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,17 +392,6 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
wasCopilotSendingRef.current = copilotIsSending
|
||||
}, [copilotIsSending, loadCopilotChats])
|
||||
|
||||
const [copilotEditingInputValue, setCopilotEditingInputValue] = useState('')
|
||||
const clearCopilotEditingValue = useCallback(() => setCopilotEditingInputValue(''), [])
|
||||
|
||||
const handleCopilotEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const msg = copilotEditQueuedMessage(id)
|
||||
if (msg) setCopilotEditingInputValue(msg.content)
|
||||
},
|
||||
[copilotEditQueuedMessage]
|
||||
)
|
||||
|
||||
const handleCopilotStopGeneration = useCallback(() => {
|
||||
captureEvent(posthogRef.current, 'task_generation_aborted', {
|
||||
workspace_id: workspaceId,
|
||||
@@ -865,11 +854,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
messageQueue={copilotMessageQueue}
|
||||
onRemoveQueuedMessage={copilotRemoveFromQueue}
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
onEditQueuedMessage={copilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={copilotResolvedChatId}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user