Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ClipboardContent,
cn,
Duplicate,
Split,
Expand All @@ -32,7 +33,7 @@ interface MessageActionsProps {
content: string
getCopyContent?: () => string
hasCopyContent?: boolean
prepareContentForCopy?: (content: string) => string
prepareContentForCopy?: (content: string) => ClipboardContent
userQuery?: string
requestId?: string
messageId?: string
Expand Down Expand Up @@ -69,9 +70,9 @@ export const MessageActions = memo(function MessageActions({
const copyToClipboard = () => {
const contentToCopy = getCopyContent?.() ?? content
if (!contentToCopy) return
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
if (!markdown) return
void copyMessage(markdown)
const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
if (typeof copyContent === 'string' && !copyContent) return
void copyMessage(copyContent)
}

const copyRequestId = async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import { describe, expect, it } from 'vitest'
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { describe, expect, it, vi } from 'vitest'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import {
prepareCopyableMarkdown,
toCopyableMarkdown,
} from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { parseChipLinks } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'

const WORKSPACE_FILES: WorkspaceFileRecord[] = [
{
id: 'file_bell',
workspaceId: 'workspace-1',
name: 'The Bell at Low Tide.md',
key: 'workspace/workspace-1/file_bell',
path: '/api/files/view/file_bell',
size: 0,
type: 'text/markdown',
uploadedBy: 'user-1',
uploadedAt: new Date(0),
updatedAt: new Date(0),
},
]

describe('toCopyableMarkdown', () => {
it('preserves message Markdown, including fenced code and its language', () => {
Expand Down Expand Up @@ -44,4 +64,99 @@ describe('toCopyableMarkdown', () => {

expect(toCopyableMarkdown(message)).toBe(message)
})

it('copies workspace resources as portable Markdown links with real ids', () => {
const message = [
'Read',
'<workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>',
'and',
`<workspace_resource>${JSON.stringify({
type: 'table',
id: 'tbl_f26af6dae98d4222b014b250494d00fb',
title: 'Checked_[rare]\\portal',
})}</workspace_resource>.`,
].join('')

const markdown = toCopyableMarkdown(message, WORKSPACE_FILES)

expect(markdown).toBe(
'Read [The Bell at Low Tide.md](sim:file/file_bell) and [Checked_\\[rare\\]\\\\portal](sim:table/tbl_f26af6dae98d4222b014b250494d00fb).'
)
expect(parseChipLinks(markdown)).toEqual([
{
kind: 'file',
id: 'file_bell',
label: 'The Bell at Low Tide.md',
start: 5,
end: 50,
},
{
kind: 'table',
id: 'tbl_f26af6dae98d4222b014b250494d00fb',
label: 'Checked_[rare]\\portal',
start: 55,
end: 129,
},
])
})

it('uses resolved file metadata for a resource without a title', () => {
const message =
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md"}</workspace_resource>.'

expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe(
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
)
})

it('refreshes missing file metadata before producing copyable Markdown', async () => {
const message =
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
const refreshWorkspaceFiles = vi.fn().mockResolvedValue(WORKSPACE_FILES)

const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)
expect(content).not.toBeTypeOf('string')
if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
expect(content.fallback).toBe('Read The Bell at Low Tide.md.')
expect(parseChipLinks(content.fallback)).toEqual([])
await expect(content.prepare()).resolves.toBe(
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
)
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
})

it('copies unresolved file references as plain text', () => {
const message =
'Read <workspace_resource>{"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}</workspace_resource>.'

const markdown = toCopyableMarkdown(message)

expect(markdown).toBe('Read Q1 plan).md.')
expect(parseChipLinks(markdown)).toEqual([])
})

it('keeps the plain-text fallback when refreshing file metadata fails', async () => {
const message =
'Read <workspace_resource>{"type":"file","path":"files/notes.md","title":"notes.md"}</workspace_resource>.'
const refreshWorkspaceFiles = vi.fn().mockRejectedValue(new Error('Refresh failed'))

const content = prepareCopyableMarkdown(message, [], refreshWorkspaceFiles)

expect(content).not.toBeTypeOf('string')
if (typeof content === 'string') throw new Error('Expected deferred clipboard content')
expect(content.fallback).toBe('Read notes.md.')
await expect(content.prepare()).resolves.toBe('Read notes.md.')
expect(refreshWorkspaceFiles).toHaveBeenCalledOnce()
})

it('does not refresh metadata when all workspace resources already resolve', () => {
const message =
'Read <workspace_resource>{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}</workspace_resource>.'
const refreshWorkspaceFiles = vi.fn()

expect(prepareCopyableMarkdown(message, WORKSPACE_FILES, refreshWorkspaceFiles)).toBe(
'Read [The Bell at Low Tide.md](sim:file/file_bell).'
)
expect(refreshWorkspaceFiles).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -1,13 +1,101 @@
import type { ClipboardContent } from '@sim/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize'
import { parseSpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import {
type ContentSegment,
parseSpecialTags,
type WorkspaceResourceTagData,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { serializePortableChipLink } from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'

export function toCopyableMarkdown(raw: string): string {
interface CopyableMarkdownResult {
markdown: string
hasUnresolvedFile: boolean
}

function workspaceResourceLabel(data: WorkspaceResourceTagData): string {
if (data.title) return data.title
return data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
}

function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
nextSegment?: ContentSegment
): string {
const followingText =
nextSegment?.type === 'text'
? nextSegment.content
: nextSegment?.type === 'workspace_resource'
? nextSegment.data.title || nextSegment.data.id || ''
: ''
const leadingSpace = /[A-Za-z0-9_)]$/.test(currentMarkdown) ? ' ' : ''
const trailingSpace =
/^[A-Za-z0-9_(]/.test(followingText) && !/\s$/.test(referenceMarkdown) ? ' ' : ''
return `${currentMarkdown}${leadingSpace}${referenceMarkdown}${trailingSpace}`
}

function portableWorkspaceResourceMarkdown(
data: WorkspaceResourceTagData,
workspaceFiles: readonly WorkspaceFileRecord[]
): CopyableMarkdownResult {
const label = workspaceResourceLabel(data)
const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles)
return {
markdown: resource
? serializePortableChipLink(data.type, resource.id, resource.title || label)
: label,
hasUnresolvedFile: data.type === 'file' && !resource,
}
}

function serializeCopyableMarkdown(
raw: string,
workspaceFiles: readonly WorkspaceFileRecord[] = []
): CopyableMarkdownResult {
const displayContent = sanitizeChatDisplayContent(raw)
const { segments } = parseSpecialTags(displayContent, false)
let hasUnresolvedFile = false

return segments
.reduce((markdown, segment) => {
return segment.type === 'text' ? markdown + segment.content : markdown
const markdown = segments
.reduce((markdown, segment, index) => {
if (segment.type === 'text') return markdown + segment.content
if (segment.type === 'workspace_resource') {
const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles)
hasUnresolvedFile ||= portable.hasUnresolvedFile
return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1])
}
return markdown
}, '')
.trim()

return { markdown, hasUnresolvedFile }
}

export function toCopyableMarkdown(
raw: string,
workspaceFiles: readonly WorkspaceFileRecord[] = []
): string {
return serializeCopyableMarkdown(raw, workspaceFiles).markdown
}

export function prepareCopyableMarkdown(
raw: string,
workspaceFiles: readonly WorkspaceFileRecord[],
refreshWorkspaceFiles: () => Promise<readonly WorkspaceFileRecord[]>
): ClipboardContent {
const initial = serializeCopyableMarkdown(raw, workspaceFiles)
if (!initial.hasUnresolvedFile) return initial.markdown

return {
fallback: initial.markdown,
prepare: async () => {
try {
return toCopyableMarkdown(raw, await refreshWorkspaceFiles())
} catch {
return initial.markdown
}
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
useRef,
useState,
} from 'react'
import { cn } from '@sim/emcn'
import { type ClipboardContent, cn } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
Expand All @@ -30,7 +32,7 @@ import {
parseLastCredentialTag,
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { toCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { prepareCopyableMarkdown } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
Expand All @@ -48,12 +50,14 @@ import type {
WorkspaceResourceRef,
} from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { getWorkspaceFilesQueryOptions, workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
import { shouldShowAssistantMessageActions } from './message-actions-visibility'

interface MothershipChatProps {
workspaceId: string
messages: ChatMessage[]
isSending: boolean
isReconnecting?: boolean
Expand Down Expand Up @@ -150,6 +154,7 @@ const LAYOUT_STYLES = {
} as const

const EMPTY_BLOCKS: ContentBlock[] = []
const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = []

interface UserMessageRowProps {
content: string
Expand Down Expand Up @@ -187,6 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({

interface AssistantMessageRowProps {
message: ChatMessage
prepareContentForCopy: (content: string) => ClipboardContent
isStreaming: boolean
isLast: boolean
precedingUserContent?: string
Expand All @@ -203,6 +209,7 @@ interface AssistantMessageRowProps {

const AssistantMessageRow = memo(function AssistantMessageRow({
message,
prepareContentForCopy,
isStreaming,
isLast,
precedingUserContent,
Expand Down Expand Up @@ -231,8 +238,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
() => getOrchestratorMessageText(blocks, message.content),
[blocks, message.content]
)
const prepareContentForCopy = useCallback((content: string) => toCopyableMarkdown(content), [])

const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
Expand Down Expand Up @@ -304,6 +309,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
})

export function MothershipChat({
workspaceId,
messages: messagesProp,
isSending,
isReconnecting = false,
Expand All @@ -329,6 +335,7 @@ export function MothershipChat({
onInputAnimationEnd,
className,
}: MothershipChatProps) {
const queryClient = useQueryClient()
const styles = LAYOUT_STYLES[layout]
const isStreamActive = isSending || isReconnecting
/**
Expand All @@ -347,6 +354,21 @@ export function MothershipChat({
const heldHighWaterRef = useRef(0)
const floorChatRef = useRef<string | undefined>(undefined)
const floorDrainRafRef = useRef(0)
const prepareContentForCopy = useCallback(
(content: string) =>
prepareCopyableMarkdown(
content,
queryClient.getQueryData<readonly WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId)
) ?? EMPTY_WORKSPACE_FILES,
() =>
queryClient.fetchQuery({
...getWorkspaceFilesQueryOptions(workspaceId),
staleTime: 0,
})
),
[queryClient, workspaceId]
)
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])

/**
Expand Down Expand Up @@ -771,6 +793,7 @@ export function MothershipChat({
) : (
<AssistantMessageRow
message={msg}
prepareContentForCopy={prepareContentForCopy}
isStreaming={isStreamActive && isLast}
isLast={isLast}
precedingUserContent={precedingUserContentByIndex[index]}
Expand Down
Loading
Loading