fix(ui): fix attachment logic on queued mothership messages (#4191)

* fix(ui): fix attachment logic on queued mothership messages

* Add focus after hitting pencil button for queued message

* fix copilot layout
This commit is contained in:
Theodore Li
2026-04-15 21:42:48 -04:00
committed by GitHub
parent 377712c9f3
commit ba6bc91681
9 changed files with 191 additions and 88 deletions
@@ -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 { useEffect, 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,28 @@ interface QueuedMessagesProps {
export function QueuedMessages({ messageQueue, onRemove, onSendNow, onEdit }: QueuedMessagesProps) {
const [isExpanded, setIsExpanded] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const [isNarrow, setIsNarrow] = useState(false)
if (messageQueue.length === 0) return null
const hasMessages = messageQueue.length > 0
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
setIsNarrow(entries[0].contentRect.width < NARROW_WIDTH_PX)
})
ro.observe(el)
return () => ro.disconnect()
}, [hasMessages])
if (!hasMessages) 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 +59,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
className='!truncate !whitespace-nowrap !text-small !leading-[20px]'
/>
</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'
@@ -12,6 +13,9 @@ const USER_MESSAGE_CLASSES =
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
}
function escapeRegex(str: string): string {
@@ -64,17 +68,23 @@ function MentionHighlight({ context }: { context: ChatMessageContext }) {
)
}
export function UserMessageContent({ content, contexts }: UserMessageContentProps) {
export function UserMessageContent({
content,
contexts,
className,
plainMentions = false,
}: UserMessageContentProps) {
const trimmed = content.trim()
const classes = cn(USER_MESSAGE_CLASSES, className)
if (!contexts || contexts.length === 0) {
return <p className={USER_MESSAGE_CLASSES}>{trimmed}</p>
return <p className={classes}>{trimmed}</p>
}
const ranges = computeMentionRanges(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 +98,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 +120,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>