Skip to content
Open
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 @@ -15,6 +15,7 @@ import {
ThumbsUp,
Tooltip,
toast,
useCopyToClipboard,
} from '@sim/emcn'
import { useParams, useRouter } from 'next/navigation'
import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
Expand All @@ -23,82 +24,54 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'

const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'

function toPlainText(raw: string): string {
return (
raw
// Strip special tags and their contents
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
// Strip markdown
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`{3}[\s\S]*?`{3}/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*]\s+/gm, '')
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
// Normalize whitespace
.replace(/\n{3,}/g, '\n\n')
.trim()
)
}

const ICON_CLASS = 'size-[14px]'
const BUTTON_CLASS =
'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'

interface MessageActionsProps {
content: string
getCopyContent?: () => string
hasCopyContent?: boolean
prepareContentForCopy?: (content: string) => string
userQuery?: string
requestId?: string
messageId?: string
}

export const MessageActions = memo(function MessageActions({
content,
getCopyContent,
hasCopyContent,
prepareContentForCopy,
userQuery,
requestId,
messageId,
}: MessageActionsProps) {
const router = useRouter()
const params = useParams<{ workspaceId: string }>()
const { chatId } = useChatSurface()
const [copied, setCopied] = useState(false)
const { copied, copy: copyMessage } = useCopyToClipboard({ resetMs: 1500 })
const [copiedRequestId, setCopiedRequestId] = useState(false)
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
const [feedbackText, setFeedbackText] = useState('')
const resetTimeoutRef = useRef<number | null>(null)
const requestIdTimeoutRef = useRef<number | null>(null)
const submitFeedback = useSubmitCopilotFeedback()
const forkChat = useForkMothershipChat(params.workspaceId)

useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
if (requestIdTimeoutRef.current !== null) {
window.clearTimeout(requestIdTimeoutRef.current)
}
}
}, [])

const copyToClipboard = async () => {
if (!content) return
const text = toPlainText(content)
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
const copyToClipboard = () => {
const contentToCopy = getCopyContent?.() ?? content
if (!contentToCopy) return
const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy
if (!markdown) return
void copyMessage(markdown)
}

const copyRequestId = async () => {
Expand Down Expand Up @@ -166,18 +139,18 @@ export const MessageActions = memo(function MessageActions({
}
}

const hasContent = Boolean(content)
const canCopyContent = hasCopyContent ?? Boolean(content)
const canSubmitFeedback = Boolean(chatId && userQuery)
// A live (just-streamed) assistant message carries a synthetic id that the
// persisted transcript doesn't know — forking it would 400. The button
// appears once the transcript refetch swaps in the persisted message id.
const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
if (!hasContent && !canSubmitFeedback && !canFork) return null
if (!canCopyContent && !canSubmitFeedback && !canFork) return null

return (
<>
<div className='flex items-center gap-0.5'>
{hasContent && (
{canCopyContent && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { JSONContent, MarkdownToken } from '@tiptap/core'
import { InputRule, Node } from '@tiptap/core'
import { toSimHref } from './sim-link'
import { toSimMarkdownLink } from './sim-link'
import type { MentionKind } from './types'

export interface MentionAttrs {
Expand All @@ -16,12 +16,7 @@ export interface MentionAttrs {
*/
const MENTION_MD_RE = /^\[((?:\\.|[^\]\\])+)\]\(sim:([a-z_]+)\/([^)\s]+)\)/

/** Escape `\`, `[`, `]` in a mention label so brackets in entity names can't break the link syntax. */
function escapeLabel(label: string): string {
return label.replace(/[\\[\]]/g, '\\$&')
}

/** Inverse of {@link escapeLabel}, applied when parsing a mention back from markdown. */
/** Inverse of the label escaping applied by {@link toSimMarkdownLink}. */
function unescapeLabel(label: string): string {
return label.replace(/\\([\\[\]])/g, '$1')
}
Expand Down Expand Up @@ -96,12 +91,12 @@ export const MarkdownMention = Node.create({
},
renderMarkdown: (node: JSONContent): string => {
const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
return toSimMarkdownLink(kind, id, label)
},

renderText: ({ node }) => {
const { kind, id, label } = node.attrs as MentionAttrs
return `[${escapeLabel(label)}](${toSimHref(kind, id)})`
return toSimMarkdownLink(kind, id, label)
},

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export function toSimHref(kind: string, id: string): string {
return `${SIM_LINK_SCHEME}:${kind}/${id}`
}

/** Builds portable mention Markdown while escaping characters that can break its label. */
export function toSimMarkdownLink(kind: string, id: string, label: string): string {
const escapedLabel = label.replace(/[\\[\]]/g, '\\$&')
return `[${escapedLabel}](${toSimHref(kind, id)})`
}

/**
* Resolves the in-app route for a clicked `sim:` mention, or `null` when the kind has no navigable
* destination. Each path matches the entity's real route: files open the file detail view,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight }
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
appendInlineReferenceMarkdown,
workspaceResourceReferenceMarkdown,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown'
import {
type ContentSegment,
type CredentialSubmissionPayload,
Expand Down Expand Up @@ -95,47 +99,6 @@ const ANIMATION_DRAIN_MS = 300
*/
const FADE_MAX_REVEALED_CHARS = 6000

function startsInlineWord(value: string): boolean {
return /^[A-Za-z0-9_(]/.test(value)
}

function endsInlineWord(value: string): boolean {
return /[A-Za-z0-9_)]$/.test(value)
}

function nextInlineSegmentLabel(segment?: ContentSegment): string {
if (!segment) return ''
// Thinking segments are never rendered, so they contribute no following text.
if (segment.type === 'text') return segment.content
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
return ''
}

function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
nextSegment?: ContentSegment
): string {
let nextMarkdown = currentMarkdown
if (currentMarkdown && endsInlineWord(currentMarkdown) && !/\s$/.test(currentMarkdown)) {
nextMarkdown += ' '
}

nextMarkdown += referenceMarkdown

const followingText = nextInlineSegmentLabel(nextSegment)
if (
followingText &&
startsInlineWord(followingText) &&
!/^\s/.test(followingText) &&
!/\s$/.test(nextMarkdown)
) {
nextMarkdown += ' '
}

return nextMarkdown
}

type TdProps = ComponentPropsWithoutRef<'td'>
type ThProps = ComponentPropsWithoutRef<'th'>

Expand Down Expand Up @@ -586,14 +549,9 @@ function ChatContentInner({
const s = parsed.segments[i]
const nextSegment = parsed.segments[i + 1]
if (s.type === 'workspace_resource') {
// Files are addressed by their encoded VFS path (copied verbatim from the tag);
// workflows/tables/KBs by id. The angle-bracket link destination keeps the path
// intact through markdown parsing (tolerates parens) without re-encoding it.
const ref = s.data.type === 'file' ? (s.data.path ?? s.data.id ?? '') : (s.data.id ?? '')
const label = s.data.title || ref
pendingMarkdown = appendInlineReferenceMarkdown(
pendingMarkdown,
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
workspaceResourceReferenceMarkdown(s.data),
nextSegment
)
} else if (s.type === 'thinking') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {
ContentSegment,
WorkspaceResourceTagData,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'

function startsInlineWord(value: string): boolean {
return /^[A-Za-z0-9_(]/.test(value)
}

function endsInlineWord(value: string): boolean {
return /[A-Za-z0-9_)]$/.test(value)
}

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

function nextInlineSegmentLabel(segment?: ContentSegment): string {
if (!segment) return ''
if (segment.type === 'text') return segment.content
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
return ''
}

export function workspaceResourceReferenceMarkdown(data: WorkspaceResourceTagData): string {
const ref = data.type === 'file' ? (data.path ?? data.id ?? '') : (data.id ?? '')
return `[${workspaceResourceLabel(data)}](<#wsres-${data.type}-${ref}>)`
}

export function appendInlineReferenceMarkdown(
currentMarkdown: string,
referenceMarkdown: string,
nextSegment?: ContentSegment
): string {
let nextMarkdown = currentMarkdown
if (currentMarkdown && endsInlineWord(currentMarkdown) && !/\s$/.test(currentMarkdown)) {
nextMarkdown += ' '
}

nextMarkdown += referenceMarkdown

const followingText = nextInlineSegmentLabel(nextSegment)
if (
followingText &&
startsInlineWord(followingText) &&
!/^\s/.test(followingText) &&
!/\s$/.test(nextMarkdown)
) {
nextMarkdown += ' '
}

return nextMarkdown
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
assistantMessageHasRenderableContent,
getRenderableMessageText,
MessageContent,
} from './message-content'
export type { MessagePhase } from './utils'
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
deriveThinkingLabel,
getRenderableMessageText,
parseBlocks,
shouldSmoothTextSegment,
} from './message-content'
Expand Down Expand Up @@ -100,6 +101,50 @@ function toolEnvelope(
} as PersistedStreamEventEnvelope
}

describe('getRenderableMessageText', () => {
it('omits span-based subagent text that has no visible agent group', () => {
const blocks: ContentBlock[] = [
subagentStart('research', 'span-visible', 'main'),
{
type: 'subagent_text',
content: 'Visible research. ',
spanId: 'span-visible',
timestamp: 2,
},
{
type: 'subagent_text',
content: 'Hidden orphan. ',
spanId: 'span-orphan',
timestamp: 3,
},
mainText('Main answer.'),
]

expect(getRenderableMessageText(blocks, 'Fallback.')).toBe('Visible research. Main answer.')
})

it('omits legacy subagent text that has no parent group', () => {
const blocks: ContentBlock[] = [
{ type: 'subagent_text', content: 'Hidden orphan. ', timestamp: 1 },
{
type: 'subagent',
content: 'research',
parentToolCallId: 'dispatch-visible',
timestamp: 2,
},
{
type: 'subagent_text',
content: 'Visible research. ',
parentToolCallId: 'dispatch-visible',
timestamp: 3,
},
mainText('Main answer.'),
]

expect(getRenderableMessageText(blocks, 'Fallback.')).toBe('Visible research. Main answer.')
})
})

describe('parseBlocks span-identity tree', () => {
it('refines a completed credential rename with its previous and new names', () => {
const segments = parseBlocks([
Expand Down
Loading
Loading