From 52154e8a9b93133b1e57ff6f85c49f303262ede0 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:43:18 -0700 Subject: [PATCH 1/2] fix(chat): copy workspace resources as portable links --- .../message-actions/message-actions.tsx | 5 +- .../rich-markdown-editor/extensions.ts | 2 +- .../rich-markdown-editor/mention/index.ts | 3 +- .../mention/mention-node.test.ts | 8 + .../mention/mention-node.ts | 28 ++- .../mention/sim-link.test.ts | 15 ++ .../rich-markdown-editor/mention/sim-link.ts | 11 -- .../components/chat-content/chat-content.tsx | 52 +----- .../workspace-resource-markdown.ts | 54 ++++++ .../mothership-chat/copyable-markdown.test.ts | 169 +++++++++++++++++- .../mothership-chat/copyable-markdown.ts | 97 +++++++++- .../mothership-chat/mothership-chat.tsx | 67 ++++++- .../components/chip-clipboard-codec.ts | 20 ++- .../app/workspace/[workspaceId]/home/home.tsx | 8 +- .../w/[workflowId]/components/panel/panel.tsx | 1 + apps/sim/hooks/queries/workspace-files.ts | 13 ++ apps/sim/lib/copilot/sim-link.ts | 30 ++++ .../src/hooks/use-copy-to-clipboard.test.ts | 65 +++++++ .../emcn/src/hooks/use-copy-to-clipboard.ts | 32 +++- packages/emcn/src/index.ts | 6 +- 20 files changed, 583 insertions(+), 103 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts create mode 100644 apps/sim/lib/copilot/sim-link.ts create mode 100644 packages/emcn/src/hooks/use-copy-to-clipboard.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 349fc95eee1..c8a2233a700 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -8,6 +8,7 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + type ClipboardContent, cn, Duplicate, Split, @@ -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 @@ -70,7 +71,7 @@ export const MessageActions = memo(function MessageActions({ const contentToCopy = getCopyContent?.() ?? content if (!contentToCopy) return const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy - if (!markdown) return + if (typeof markdown === 'string' && !markdown) return void copyMessage(markdown) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 8c7d42342f9..be28d166f31 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -11,12 +11,12 @@ import { } from '@tiptap/extension-table' import { Markdown } from '@tiptap/markdown' import StarterKit from '@tiptap/starter-kit' +import { SIM_LINK_SCHEME } from '@/lib/copilot/sim-link' import { MarkdownCodeBlock } from './code-block-schema' import { Highlight } from './highlight' import { MarkdownImage } from './image-schema' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownMention } from './mention/mention-node' -import { SIM_LINK_SCHEME } from './mention/sim-link' import { FootnoteDef, FootnoteRef, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 1461b83b382..59484e8d768 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,7 +1,8 @@ +export { SIM_LINK_SCHEME, toSimHref } from '@/lib/copilot/sim-link' export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' export { MentionChip } from './mention-chip' export { MarkdownMention } from './mention-node' -export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' +export { simLinkPath } from './sim-link' export type { MentionItem, MentionKind } from './types' export { useEditorMentions } from './use-editor-mentions' export { useMarkdownMentions } from './use-markdown-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts index ec6447ff88b..b0a1f1e6757 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -44,6 +44,14 @@ describe('mention node round-trip', () => { expect(serializeMarkdownBody(input).trim()).toBe(input) }) + it('round-trips a file reference containing whitespace and a closing parenthesis', () => { + const input = '[Q1 plan](sim:file/files/Q1%20plan%29.md)' + const doc = parseMarkdownToDoc(input) + const mention = findMention(doc) + expect(mention?.attrs).toEqual({ kind: 'file', id: 'files/Q1 plan).md', label: 'Q1 plan' }) + expect(serializeMarkdownBody(input).trim()).toBe(input) + }) + it('leaves a normal http link as a link, not a mention', () => { const doc = parseMarkdownToDoc('[Sim](https://sim.ai)') expect(findMention(doc)).toBeNull() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts index af63ff8e40f..d85d6b9c264 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts @@ -1,6 +1,6 @@ import type { JSONContent, MarkdownToken } from '@tiptap/core' import { InputRule, Node } from '@tiptap/core' -import { toSimHref } from './sim-link' +import { fromSimHrefId, fromSimMarkdownLabel, toSimMarkdownLink } from '@/lib/copilot/sim-link' import type { MentionKind } from './types' export interface MentionAttrs { @@ -16,16 +16,6 @@ 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. */ -function unescapeLabel(label: string): string { - return label.replace(/\\([\\[\]])/g, '$1') -} - /** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ interface MentionTokenFields { label?: string @@ -91,17 +81,21 @@ export const MarkdownMention = Node.create({ const { kind, id, label } = token as MentionTokenFields return { type: 'mention', - attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') }, + attrs: { + kind: kind ?? '', + id: fromSimHrefId(id ?? ''), + label: fromSimMarkdownLabel(label ?? ''), + }, } }, 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) }, /** @@ -123,7 +117,11 @@ export const MarkdownMention = Node.create({ state.tr.replaceWith( range.from, range.to, - type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') }) + type.create({ + kind, + id: fromSimHrefId(id), + label: fromSimMarkdownLabel(rawLabel ?? ''), + }) ) }, }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts index 5efe8b66bd6..410d6383875 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from 'vitest' +import { fromSimHrefId, toSimHref } from '@/lib/copilot/sim-link' import { simLinkPath } from './sim-link' +describe('sim link id codec', () => { + it('round-trips identifiers containing link delimiters', () => { + const id = 'files/Q1 plan).md' + const href = toSimHref('file', id) + + expect(href).toBe('sim:file/files/Q1%20plan%29.md') + expect(fromSimHrefId(href.slice('sim:file/'.length))).toBe(id) + }) + + it('leaves malformed percent encoding intact', () => { + expect(fromSimHrefId('file%2')).toBe('file%2') + }) +}) + describe('simLinkPath', () => { const ws = 'ws1' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts index 5af0b6065e8..94b5918fb66 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -1,14 +1,3 @@ -/** - * The link scheme for `@`-mention links — `[label](sim:/)`. Matches the chat composer's - * portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there. - */ -export const SIM_LINK_SCHEME = 'sim' - -/** Builds the link target for a mention of `kind`/`id`. */ -export function toSimHref(kind: string, id: string): string { - return `${SIM_LINK_SCHEME}:${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, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index c143c72bb4a..f0f8b4a73a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -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, @@ -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'> @@ -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') { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts new file mode 100644 index 00000000000..2a338469a08 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts @@ -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 +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts index 851bd0c1fc9..aeb2442c325 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts @@ -1,5 +1,33 @@ -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' + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { getOrchestratorMessageText } from '@/app/workspace/[workspaceId]/home/components/message-content' +import { + prepareCopyableMarkdown, + serializeCopyableMarkdown, + 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' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' + +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', () => { @@ -44,4 +72,141 @@ describe('toCopyableMarkdown', () => { expect(toCopyableMarkdown(message)).toBe(message) }) + + it('copies workspace resources as portable Markdown links with real ids', () => { + const message = [ + 'Read', + '{"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}', + 'and', + `${JSON.stringify({ + type: 'table', + id: 'tbl_f26af6dae98d4222b014b250494d00fb', + title: 'Checked_[rare]\\portal', + })}.`, + ].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 {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md"}.' + + expect(toCopyableMarkdown(message, WORKSPACE_FILES)).toBe( + 'Read [The Bell at Low Tide.md](sim:file/file_bell).' + ) + }) + + it('uses cached names for workflow and table labels shown in the chat', () => { + const message = [ + '{"type":"workflow","id":"workflow-1","title":"Old workflow name"}', + '{"type":"table","id":"table-1"}', + ].join(' and ') + + expect( + toCopyableMarkdown(message, [], { + workflow: new Map([['workflow-1', 'Current workflow name']]), + table: new Map([['table-1', 'Current table name']]), + }) + ).toBe( + '[Current workflow name](sim:workflow/workflow-1) and [Current table name](sim:table/table-1)' + ) + }) + + it('reports file resources that need refreshed metadata before copying', () => { + const message = + 'Read {"type":"file","path":"files/notes.md","title":"notes.md"}.' + + expect(serializeCopyableMarkdown(message)).toEqual({ + markdown: 'Read notes.md.', + hasUnresolvedFile: true, + }) + }) + + it('refreshes missing file metadata before producing copyable Markdown', async () => { + const message = + 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + 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 {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.' + + const { markdown } = serializeCopyableMarkdown(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 {"type":"file","path":"files/notes.md","title":"notes.md"}.' + 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 {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' + 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() + }) + + it('copies workspace resources from orchestrator content blocks', () => { + const contentBlocks: ContentBlock[] = [ + { type: 'text', content: 'Read ' }, + { type: 'thinking', content: 'Do not copy this.' }, + { + type: 'text', + content: + '{"type":"file","path":"files/notes.md","title":"notes.md"} for details.', + }, + ] + + const content = getOrchestratorMessageText(contentBlocks, 'Fallback without the resource.') + + expect(toCopyableMarkdown(content, WORKSPACE_FILES)).toBe('Read notes.md for details.') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts index 8d0fc40e0b4..e43d9665e47 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts @@ -1,13 +1,100 @@ +import type { ClipboardContent } from '@sim/emcn' +import { toSimMarkdownLink } from '@/lib/copilot/sim-link' +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 { + appendInlineReferenceMarkdown, + workspaceResourceLabel, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown' +import { + parseSpecialTags, + type WorkspaceResourceTagData, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' -export function toCopyableMarkdown(raw: string): string { +interface PortableWorkspaceResourceMarkdown { + markdown: string + hasUnresolvedFile: boolean +} + +export interface WorkspaceResourceNames { + workflow?: ReadonlyMap + table?: ReadonlyMap +} + +function portableWorkspaceResourceMarkdown( + data: WorkspaceResourceTagData, + workspaceFiles: readonly WorkspaceFileRecord[], + resourceNames: WorkspaceResourceNames +): PortableWorkspaceResourceMarkdown { + const label = workspaceResourceLabel(data) + const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles) + const cachedLabel = + resource && data.type !== 'file' ? resourceNames[data.type]?.get(resource.id) : undefined + const resolvedLabel = cachedLabel ?? resource?.title + return { + markdown: resource + ? toSimMarkdownLink(resource.type, resource.id, resolvedLabel || label) + : label, + hasUnresolvedFile: data.type === 'file' && !resource, + } +} + +export interface CopyableMarkdownResult { + markdown: string + hasUnresolvedFile: boolean +} + +export function serializeCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[] = [], + resourceNames: WorkspaceResourceNames = {} +): 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, + resourceNames + ) + 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[] = [], + resourceNames: WorkspaceResourceNames = {} +): string { + return serializeCopyableMarkdown(raw, workspaceFiles, resourceNames).markdown +} + +export function prepareCopyableMarkdown( + raw: string, + workspaceFiles: readonly WorkspaceFileRecord[], + refreshWorkspaceFiles: () => Promise, + resourceNames: WorkspaceResourceNames = {} +): ClipboardContent { + const initial = serializeCopyableMarkdown(raw, workspaceFiles, resourceNames) + if (!initial.hasUnresolvedFile) return initial.markdown + + return { + fallback: initial.markdown, + prepare: () => + refreshWorkspaceFiles() + .catch(() => workspaceFiles) + .then((refreshedFiles) => toCopyableMarkdown(raw, refreshedFiles, resourceNames)), + } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 756d80ed984..c926bb22bfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -11,8 +11,10 @@ import { useState, } from 'react' import { cn } from '@sim/emcn' +import { type QueryClient, 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' @@ -30,7 +32,10 @@ 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, + type WorkspaceResourceNames, +} 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 { @@ -48,13 +53,18 @@ import type { WorkspaceResourceRef, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { tableKeys } from '@/hooks/queries/utils/table-keys' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { fetchFreshWorkspaceFiles } 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[] + workspaceFiles?: readonly WorkspaceFileRecord[] isSending: boolean isReconnecting?: boolean isLoading?: boolean @@ -150,6 +160,30 @@ const LAYOUT_STYLES = { } as const const EMPTY_BLOCKS: ContentBlock[] = [] +const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = [] + +interface NamedWorkspaceResource { + id: string + name: string +} + +function cachedWorkspaceResourceNames( + queryClient: QueryClient, + workspaceId: string +): WorkspaceResourceNames { + const workflows = + queryClient.getQueryData( + workflowKeys.list(workspaceId, 'active') + ) ?? [] + const tables = + queryClient.getQueryData( + tableKeys.list(workspaceId, 'active') + ) ?? [] + return { + workflow: new Map(workflows.map((workflow) => [workflow.id, workflow.name])), + table: new Map(tables.map((table) => [table.id, table.name])), + } +} interface UserMessageRowProps { content: string @@ -187,6 +221,9 @@ const UserMessageRow = memo(function UserMessageRow({ interface AssistantMessageRowProps { message: ChatMessage + workspaceFiles: readonly WorkspaceFileRecord[] + refreshWorkspaceFiles: () => Promise + getWorkspaceResourceNames: () => WorkspaceResourceNames isStreaming: boolean isLast: boolean precedingUserContent?: string @@ -203,6 +240,9 @@ interface AssistantMessageRowProps { const AssistantMessageRow = memo(function AssistantMessageRow({ message, + workspaceFiles, + refreshWorkspaceFiles, + getWorkspaceResourceNames, isStreaming, isLast, precedingUserContent, @@ -231,7 +271,16 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ () => getOrchestratorMessageText(blocks, message.content), [blocks, message.content] ) - const prepareContentForCopy = useCallback((content: string) => toCopyableMarkdown(content), []) + const prepareContentForCopy = useCallback( + (content: string) => + prepareCopyableMarkdown( + content, + workspaceFiles, + refreshWorkspaceFiles, + getWorkspaceResourceNames() + ), + [getWorkspaceResourceNames, refreshWorkspaceFiles, workspaceFiles] + ) const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '') if (!hasRenderableAssistant && !trimmedContent && !isStreaming) { @@ -304,7 +353,9 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ }) export function MothershipChat({ + workspaceId, messages: messagesProp, + workspaceFiles = EMPTY_WORKSPACE_FILES, isSending, isReconnecting = false, isLoading = false, @@ -329,6 +380,7 @@ export function MothershipChat({ onInputAnimationEnd, className, }: MothershipChatProps) { + const queryClient = useQueryClient() const styles = LAYOUT_STYLES[layout] const isStreamActive = isSending || isReconnecting /** @@ -347,6 +399,14 @@ export function MothershipChat({ const heldHighWaterRef = useRef(0) const floorChatRef = useRef(undefined) const floorDrainRafRef = useRef(0) + const refreshWorkspaceFiles = useCallback( + () => fetchFreshWorkspaceFiles(queryClient, workspaceId), + [queryClient, workspaceId] + ) + const getWorkspaceResourceNames = useCallback( + () => cachedWorkspaceResourceNames(queryClient, workspaceId), + [queryClient, workspaceId] + ) useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) /** @@ -771,6 +831,9 @@ export function MothershipChat({ ) : ( /`. This is the one map @@ -45,12 +47,12 @@ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD /** * Matches a portable chip markdown link: `[label](sim:kind/id)`. - * - group 1: label (any non-`]` chars) + * - group 1: label (plain or backslash-escaped characters) * - group 2: kind (lowercase letters / underscores, e.g. `past_chat`) * - group 3: id (any non-`)` / non-whitespace chars) */ const CHIP_LINK_PATTERN = new RegExp( - `\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, + `\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${SIM_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, 'g' ) @@ -96,7 +98,7 @@ function serializeChipContext(context: ChatContext): string | null { if (!isPortableKind(context.kind)) return null const id = getPortableId(context) if (!id) return null - return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})` + return toSimMarkdownLink(context.kind, id, context.label) } /** @@ -204,8 +206,8 @@ export function parseChipLinks(text: string): ParsedChipLink[] { if (!isPortableKind(kind)) continue links.push({ kind, - id, - label, + id: fromSimHrefId(id), + label: fromSimMarkdownLabel(label), start: match.index, end: match.index + full.length, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 9ec1eab1573..5457a7f573f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -43,7 +43,7 @@ import { useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' -import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { fetchFreshWorkspaceFiles, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -563,9 +563,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) // handler is invoked as a void callback, so failure becomes null rather // than an unhandled rejection — and stays distinct from an empty list, so // "we could not look" is never reported as "it is not there". - const files = await queryClient - .fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 }) - .catch(() => null) + const files = await fetchFreshWorkspaceFiles(queryClient, workspaceId).catch(() => null) const resolved = files && resolveWorkspaceResourceRef(ref, files) if (resolved) { openWorkspaceResource(resolved) @@ -652,7 +650,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) ) : ( { + return queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId, scope), + staleTime: WORKSPACE_FILES_FORCE_REFRESH_STALE_TIME, + }) +} + /** * Hook to fetch workspace files */ diff --git a/apps/sim/lib/copilot/sim-link.ts b/apps/sim/lib/copilot/sim-link.ts new file mode 100644 index 00000000000..4ba967c4517 --- /dev/null +++ b/apps/sim/lib/copilot/sim-link.ts @@ -0,0 +1,30 @@ +/** The link scheme for portable workspace resource mentions. */ +export const SIM_LINK_SCHEME = 'sim' + +/** Builds the link target for a mention of `kind`/`id`. */ +export function toSimHref(kind: string, id: string): string { + const encodedId = encodeURIComponent(id) + .replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%2F/gi, '/') + return `${SIM_LINK_SCHEME}:${kind}/${encodedId}` +} + +/** 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)})` +} + +/** Restores a label serialized by {@link toSimMarkdownLink}. */ +export function fromSimMarkdownLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} + +/** Restores an identifier serialized into a `sim:` link without throwing on malformed input. */ +export function fromSimHrefId(id: string): string { + try { + return decodeURIComponent(id) + } catch { + return id + } +} diff --git a/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts b/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts new file mode 100644 index 00000000000..068404b444e --- /dev/null +++ b/packages/emcn/src/hooks/use-copy-to-clipboard.test.ts @@ -0,0 +1,65 @@ +import { writeTextToClipboard } from '@sim/emcn' +import { afterEach, describe, expect, it, vi } from 'vitest' + +interface MockClipboardItem { + items: Record> +} + +describe('writeTextToClipboard', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('writes prepared text directly', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + await writeTextToClipboard('ready') + + expect(writeText).toHaveBeenCalledWith('ready') + }) + + it('starts a ClipboardItem write before promised text resolves', async () => { + const write = vi.fn().mockResolvedValue(undefined) + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { write, writeText } }) + vi.stubGlobal( + 'ClipboardItem', + class { + constructor(readonly items: Record>) {} + } + ) + let resolveText: (value: string) => void = () => undefined + const text = new Promise((resolve) => { + resolveText = resolve + }) + + const result = writeTextToClipboard({ fallback: 'available now', prepare: () => text }) + + expect(write).toHaveBeenCalledOnce() + expect(writeText).not.toHaveBeenCalled() + const [clipboardItems] = write.mock.calls[0] as [MockClipboardItem[]] + resolveText('prepared later') + const blob = await clipboardItems[0].items['text/plain'] + expect(await blob.text()).toBe('prepared later') + await result + }) + + it('writes the immediate fallback when ClipboardItem is unavailable', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.stubGlobal('ClipboardItem', undefined) + let resolveText: (value: string) => void = () => undefined + const text = new Promise((resolve) => { + resolveText = resolve + }) + const prepare = vi.fn(() => text) + + const result = writeTextToClipboard({ fallback: 'available now', prepare }) + + expect(writeText).toHaveBeenCalledWith('available now') + expect(prepare).not.toHaveBeenCalled() + await result + resolveText('prepared later') + }) +}) diff --git a/packages/emcn/src/hooks/use-copy-to-clipboard.ts b/packages/emcn/src/hooks/use-copy-to-clipboard.ts index 751a94cf76c..d8fc0397ae9 100644 --- a/packages/emcn/src/hooks/use-copy-to-clipboard.ts +++ b/packages/emcn/src/hooks/use-copy-to-clipboard.ts @@ -7,9 +7,35 @@ interface UseCopyToClipboardOptions { resetMs?: number } +export interface DeferredClipboardContent { + /** Safe text that can be written immediately when promise-backed writes are unavailable. */ + fallback: string + /** Produces the preferred text when the browser supports promise-backed clipboard items. */ + prepare: () => Promise +} + +export type ClipboardContent = string | DeferredClipboardContent + interface UseCopyToClipboardReturn { copied: boolean - copy: (text: string) => Promise + copy: (content: ClipboardContent) => Promise +} + +/** + * Starts an async clipboard write while the caller still has transient user activation. + * Deferred text uses `ClipboardItem` when available and an immediate fallback otherwise. + */ +export function writeTextToClipboard(content: ClipboardContent): Promise { + if (typeof content === 'string') return navigator.clipboard.writeText(content) + + if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') { + const blob = Promise.resolve() + .then(() => content.prepare()) + .then((value) => new Blob([value], { type: 'text/plain' })) + return navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]) + } + + return navigator.clipboard.writeText(content.fallback) } /** @@ -34,9 +60,9 @@ export function useCopyToClipboard( const timerRef = useRef | null>(null) const copy = useCallback( - async (text: string): Promise => { + async (content: ClipboardContent): Promise => { try { - await navigator.clipboard.writeText(text) + await writeTextToClipboard(content) setCopied(true) if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => setCopied(false), resetMs) diff --git a/packages/emcn/src/index.ts b/packages/emcn/src/index.ts index cdb37cf1af2..41bff34627c 100644 --- a/packages/emcn/src/index.ts +++ b/packages/emcn/src/index.ts @@ -33,7 +33,11 @@ export { TableHeader, TableRow, } from './components/table/table' -export { useCopyToClipboard } from './hooks/use-copy-to-clipboard' +export { + type ClipboardContent, + useCopyToClipboard, + writeTextToClipboard, +} from './hooks/use-copy-to-clipboard' export { usePrefersReducedMotion } from './hooks/use-prefers-reduced-motion' export * from './icons' export { cn } from './lib/cn' From e7ffc0723d8e33037b64706266f0362d6de2098e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:56:36 -0700 Subject: [PATCH 2/2] refactor(chat): simplify portable resource copying --- .../message-actions/message-actions.tsx | 6 +- .../rich-markdown-editor/extensions.ts | 2 +- .../rich-markdown-editor/mention/index.ts | 3 +- .../mention/mention-node.test.ts | 8 -- .../mention/mention-node.ts | 28 ++++--- .../mention/sim-link.test.ts | 15 ---- .../rich-markdown-editor/mention/sim-link.ts | 11 +++ .../components/chat-content/chat-content.tsx | 52 ++++++++++-- .../workspace-resource-markdown.ts | 54 ------------- .../mothership-chat/copyable-markdown.test.ts | 54 +------------ .../mothership-chat/copyable-markdown.ts | 79 +++++++++--------- .../mothership-chat/mothership-chat.tsx | 80 +++++-------------- .../components/chip-clipboard-codec.ts | 28 ++++--- .../app/workspace/[workspaceId]/home/home.tsx | 7 +- apps/sim/hooks/queries/workspace-files.ts | 13 --- apps/sim/lib/copilot/sim-link.ts | 30 ------- 16 files changed, 162 insertions(+), 308 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts delete mode 100644 apps/sim/lib/copilot/sim-link.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index c8a2233a700..2baae14945d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -70,9 +70,9 @@ export const MessageActions = memo(function MessageActions({ const copyToClipboard = () => { const contentToCopy = getCopyContent?.() ?? content if (!contentToCopy) return - const markdown = prepareContentForCopy?.(contentToCopy) ?? contentToCopy - if (typeof markdown === 'string' && !markdown) return - void copyMessage(markdown) + const copyContent = prepareContentForCopy?.(contentToCopy) ?? contentToCopy + if (typeof copyContent === 'string' && !copyContent) return + void copyMessage(copyContent) } const copyRequestId = async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index be28d166f31..8c7d42342f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -11,12 +11,12 @@ import { } from '@tiptap/extension-table' import { Markdown } from '@tiptap/markdown' import StarterKit from '@tiptap/starter-kit' -import { SIM_LINK_SCHEME } from '@/lib/copilot/sim-link' import { MarkdownCodeBlock } from './code-block-schema' import { Highlight } from './highlight' import { MarkdownImage } from './image-schema' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownMention } from './mention/mention-node' +import { SIM_LINK_SCHEME } from './mention/sim-link' import { FootnoteDef, FootnoteRef, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts index 59484e8d768..1461b83b382 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts @@ -1,8 +1,7 @@ -export { SIM_LINK_SCHEME, toSimHref } from '@/lib/copilot/sim-link' export { MENTION_PLUGIN_KEY, Mention, type MentionStorage } from './mention' export { MentionChip } from './mention-chip' export { MarkdownMention } from './mention-node' -export { simLinkPath } from './sim-link' +export { SIM_LINK_SCHEME, simLinkPath, toSimHref } from './sim-link' export type { MentionItem, MentionKind } from './types' export { useEditorMentions } from './use-editor-mentions' export { useMarkdownMentions } from './use-markdown-mentions' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts index b0a1f1e6757..ec6447ff88b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.test.ts @@ -44,14 +44,6 @@ describe('mention node round-trip', () => { expect(serializeMarkdownBody(input).trim()).toBe(input) }) - it('round-trips a file reference containing whitespace and a closing parenthesis', () => { - const input = '[Q1 plan](sim:file/files/Q1%20plan%29.md)' - const doc = parseMarkdownToDoc(input) - const mention = findMention(doc) - expect(mention?.attrs).toEqual({ kind: 'file', id: 'files/Q1 plan).md', label: 'Q1 plan' }) - expect(serializeMarkdownBody(input).trim()).toBe(input) - }) - it('leaves a normal http link as a link, not a mention', () => { const doc = parseMarkdownToDoc('[Sim](https://sim.ai)') expect(findMention(doc)).toBeNull() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts index d85d6b9c264..af63ff8e40f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-node.ts @@ -1,6 +1,6 @@ import type { JSONContent, MarkdownToken } from '@tiptap/core' import { InputRule, Node } from '@tiptap/core' -import { fromSimHrefId, fromSimMarkdownLabel, toSimMarkdownLink } from '@/lib/copilot/sim-link' +import { toSimHref } from './sim-link' import type { MentionKind } from './types' export interface MentionAttrs { @@ -16,6 +16,16 @@ 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. */ +function unescapeLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} + /** Custom fields the mention tokenizer hangs on the marked token (all optional, like the image token). */ interface MentionTokenFields { label?: string @@ -81,21 +91,17 @@ export const MarkdownMention = Node.create({ const { kind, id, label } = token as MentionTokenFields return { type: 'mention', - attrs: { - kind: kind ?? '', - id: fromSimHrefId(id ?? ''), - label: fromSimMarkdownLabel(label ?? ''), - }, + attrs: { kind: kind ?? '', id: id ?? '', label: unescapeLabel(label ?? '') }, } }, renderMarkdown: (node: JSONContent): string => { const { kind, id, label } = (node.attrs ?? {}) as MentionAttrs - return toSimMarkdownLink(kind, id, label) + return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, renderText: ({ node }) => { const { kind, id, label } = node.attrs as MentionAttrs - return toSimMarkdownLink(kind, id, label) + return `[${escapeLabel(label)}](${toSimHref(kind, id)})` }, /** @@ -117,11 +123,7 @@ export const MarkdownMention = Node.create({ state.tr.replaceWith( range.from, range.to, - type.create({ - kind, - id: fromSimHrefId(id), - label: fromSimMarkdownLabel(rawLabel ?? ''), - }) + type.create({ kind, id, label: unescapeLabel(rawLabel ?? '') }) ) }, }), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts index 410d6383875..5efe8b66bd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -1,21 +1,6 @@ import { describe, expect, it } from 'vitest' -import { fromSimHrefId, toSimHref } from '@/lib/copilot/sim-link' import { simLinkPath } from './sim-link' -describe('sim link id codec', () => { - it('round-trips identifiers containing link delimiters', () => { - const id = 'files/Q1 plan).md' - const href = toSimHref('file', id) - - expect(href).toBe('sim:file/files/Q1%20plan%29.md') - expect(fromSimHrefId(href.slice('sim:file/'.length))).toBe(id) - }) - - it('leaves malformed percent encoding intact', () => { - expect(fromSimHrefId('file%2')).toBe('file%2') - }) -}) - describe('simLinkPath', () => { const ws = 'ws1' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts index 94b5918fb66..5af0b6065e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -1,3 +1,14 @@ +/** + * The link scheme for `@`-mention links — `[label](sim:/)`. Matches the chat composer's + * portable chip format (`chip-clipboard-codec.ts`), so a mention authored here is parseable there. + */ +export const SIM_LINK_SCHEME = 'sim' + +/** Builds the link target for a mention of `kind`/`id`. */ +export function toSimHref(kind: string, id: string): string { + return `${SIM_LINK_SCHEME}:${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, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index f0f8b4a73a6..c143c72bb4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -15,10 +15,6 @@ 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, @@ -99,6 +95,47 @@ 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'> @@ -549,9 +586,14 @@ 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, - workspaceResourceReferenceMarkdown(s.data), + `[${label}](<#wsres-${s.data.type}-${ref}>)`, nextSegment ) } else if (s.type === 'thinking') { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts deleted file mode 100644 index 2a338469a08..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts index aeb2442c325..1bdc3dd78a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.test.ts @@ -1,18 +1,10 @@ import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/auth/auth-client', () => ({ - useSession: vi.fn(() => ({ data: null, isPending: false })), -})) - import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { getOrchestratorMessageText } from '@/app/workspace/[workspaceId]/home/components/message-content' import { prepareCopyableMarkdown, - serializeCopyableMarkdown, 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' -import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' const WORKSPACE_FILES: WorkspaceFileRecord[] = [ { @@ -83,7 +75,7 @@ describe('toCopyableMarkdown', () => { id: 'tbl_f26af6dae98d4222b014b250494d00fb', title: 'Checked_[rare]\\portal', })}.`, - ].join(' ') + ].join('') const markdown = toCopyableMarkdown(message, WORKSPACE_FILES) @@ -117,32 +109,6 @@ describe('toCopyableMarkdown', () => { ) }) - it('uses cached names for workflow and table labels shown in the chat', () => { - const message = [ - '{"type":"workflow","id":"workflow-1","title":"Old workflow name"}', - '{"type":"table","id":"table-1"}', - ].join(' and ') - - expect( - toCopyableMarkdown(message, [], { - workflow: new Map([['workflow-1', 'Current workflow name']]), - table: new Map([['table-1', 'Current table name']]), - }) - ).toBe( - '[Current workflow name](sim:workflow/workflow-1) and [Current table name](sim:table/table-1)' - ) - }) - - it('reports file resources that need refreshed metadata before copying', () => { - const message = - 'Read {"type":"file","path":"files/notes.md","title":"notes.md"}.' - - expect(serializeCopyableMarkdown(message)).toEqual({ - markdown: 'Read notes.md.', - hasUnresolvedFile: true, - }) - }) - it('refreshes missing file metadata before producing copyable Markdown', async () => { const message = 'Read {"type":"file","path":"files/The%20Bell%20at%20Low%20Tide.md","title":"The Bell at Low Tide.md"}.' @@ -163,7 +129,7 @@ describe('toCopyableMarkdown', () => { const message = 'Read {"type":"file","path":"files/Q1 plan).md","title":"Q1 plan).md"}.' - const { markdown } = serializeCopyableMarkdown(message) + const markdown = toCopyableMarkdown(message) expect(markdown).toBe('Read Q1 plan).md.') expect(parseChipLinks(markdown)).toEqual([]) @@ -193,20 +159,4 @@ describe('toCopyableMarkdown', () => { ) expect(refreshWorkspaceFiles).not.toHaveBeenCalled() }) - - it('copies workspace resources from orchestrator content blocks', () => { - const contentBlocks: ContentBlock[] = [ - { type: 'text', content: 'Read ' }, - { type: 'thinking', content: 'Do not copy this.' }, - { - type: 'text', - content: - '{"type":"file","path":"files/notes.md","title":"notes.md"} for details.', - }, - ] - - const content = getOrchestratorMessageText(contentBlocks, 'Fallback without the resource.') - - expect(toCopyableMarkdown(content, WORKSPACE_FILES)).toBe('Read notes.md for details.') - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts index e43d9665e47..0a27f697f6a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts @@ -1,54 +1,58 @@ import type { ClipboardContent } from '@sim/emcn' -import { toSimMarkdownLink } from '@/lib/copilot/sim-link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { sanitizeChatDisplayContent } from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-sanitize' import { - appendInlineReferenceMarkdown, - workspaceResourceLabel, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/workspace-resource-markdown' -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' -interface PortableWorkspaceResourceMarkdown { +interface CopyableMarkdownResult { markdown: string hasUnresolvedFile: boolean } -export interface WorkspaceResourceNames { - workflow?: ReadonlyMap - table?: ReadonlyMap +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[], - resourceNames: WorkspaceResourceNames -): PortableWorkspaceResourceMarkdown { + workspaceFiles: readonly WorkspaceFileRecord[] +): CopyableMarkdownResult { const label = workspaceResourceLabel(data) const resource = resolveWorkspaceResourceRef({ ...data, title: data.title ?? '' }, workspaceFiles) - const cachedLabel = - resource && data.type !== 'file' ? resourceNames[data.type]?.get(resource.id) : undefined - const resolvedLabel = cachedLabel ?? resource?.title return { markdown: resource - ? toSimMarkdownLink(resource.type, resource.id, resolvedLabel || label) + ? serializePortableChipLink(data.type, resource.id, resource.title || label) : label, hasUnresolvedFile: data.type === 'file' && !resource, } } -export interface CopyableMarkdownResult { - markdown: string - hasUnresolvedFile: boolean -} - -export function serializeCopyableMarkdown( +function serializeCopyableMarkdown( raw: string, - workspaceFiles: readonly WorkspaceFileRecord[] = [], - resourceNames: WorkspaceResourceNames = {} + workspaceFiles: readonly WorkspaceFileRecord[] = [] ): CopyableMarkdownResult { const displayContent = sanitizeChatDisplayContent(raw) const { segments } = parseSpecialTags(displayContent, false) @@ -58,11 +62,7 @@ export function serializeCopyableMarkdown( .reduce((markdown, segment, index) => { if (segment.type === 'text') return markdown + segment.content if (segment.type === 'workspace_resource') { - const portable = portableWorkspaceResourceMarkdown( - segment.data, - workspaceFiles, - resourceNames - ) + const portable = portableWorkspaceResourceMarkdown(segment.data, workspaceFiles) hasUnresolvedFile ||= portable.hasUnresolvedFile return appendInlineReferenceMarkdown(markdown, portable.markdown, segments[index + 1]) } @@ -75,26 +75,27 @@ export function serializeCopyableMarkdown( export function toCopyableMarkdown( raw: string, - workspaceFiles: readonly WorkspaceFileRecord[] = [], - resourceNames: WorkspaceResourceNames = {} + workspaceFiles: readonly WorkspaceFileRecord[] = [] ): string { - return serializeCopyableMarkdown(raw, workspaceFiles, resourceNames).markdown + return serializeCopyableMarkdown(raw, workspaceFiles).markdown } export function prepareCopyableMarkdown( raw: string, workspaceFiles: readonly WorkspaceFileRecord[], - refreshWorkspaceFiles: () => Promise, - resourceNames: WorkspaceResourceNames = {} + refreshWorkspaceFiles: () => Promise ): ClipboardContent { - const initial = serializeCopyableMarkdown(raw, workspaceFiles, resourceNames) + const initial = serializeCopyableMarkdown(raw, workspaceFiles) if (!initial.hasUnresolvedFile) return initial.markdown return { fallback: initial.markdown, - prepare: () => - refreshWorkspaceFiles() - .catch(() => workspaceFiles) - .then((refreshedFiles) => toCopyableMarkdown(raw, refreshedFiles, resourceNames)), + prepare: async () => { + try { + return toCopyableMarkdown(raw, await refreshWorkspaceFiles()) + } catch { + return initial.markdown + } + }, } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index c926bb22bfe..0bd179c7d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -10,8 +10,8 @@ import { useRef, useState, } from 'react' -import { cn } from '@sim/emcn' -import { type QueryClient, useQueryClient } from '@tanstack/react-query' +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' @@ -32,10 +32,7 @@ import { parseLastCredentialTag, parseLastQuestionTag, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' -import { - prepareCopyableMarkdown, - type WorkspaceResourceNames, -} 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 { @@ -53,9 +50,7 @@ import type { WorkspaceResourceRef, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' -import { fetchFreshWorkspaceFiles } from '@/hooks/queries/workspace-files' +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' @@ -64,7 +59,6 @@ import { shouldShowAssistantMessageActions } from './message-actions-visibility' interface MothershipChatProps { workspaceId: string messages: ChatMessage[] - workspaceFiles?: readonly WorkspaceFileRecord[] isSending: boolean isReconnecting?: boolean isLoading?: boolean @@ -162,29 +156,6 @@ const LAYOUT_STYLES = { const EMPTY_BLOCKS: ContentBlock[] = [] const EMPTY_WORKSPACE_FILES: readonly WorkspaceFileRecord[] = [] -interface NamedWorkspaceResource { - id: string - name: string -} - -function cachedWorkspaceResourceNames( - queryClient: QueryClient, - workspaceId: string -): WorkspaceResourceNames { - const workflows = - queryClient.getQueryData( - workflowKeys.list(workspaceId, 'active') - ) ?? [] - const tables = - queryClient.getQueryData( - tableKeys.list(workspaceId, 'active') - ) ?? [] - return { - workflow: new Map(workflows.map((workflow) => [workflow.id, workflow.name])), - table: new Map(tables.map((table) => [table.id, table.name])), - } -} - interface UserMessageRowProps { content: string contexts?: ChatMessageContext[] @@ -221,9 +192,7 @@ const UserMessageRow = memo(function UserMessageRow({ interface AssistantMessageRowProps { message: ChatMessage - workspaceFiles: readonly WorkspaceFileRecord[] - refreshWorkspaceFiles: () => Promise - getWorkspaceResourceNames: () => WorkspaceResourceNames + prepareContentForCopy: (content: string) => ClipboardContent isStreaming: boolean isLast: boolean precedingUserContent?: string @@ -240,9 +209,7 @@ interface AssistantMessageRowProps { const AssistantMessageRow = memo(function AssistantMessageRow({ message, - workspaceFiles, - refreshWorkspaceFiles, - getWorkspaceResourceNames, + prepareContentForCopy, isStreaming, isLast, precedingUserContent, @@ -271,17 +238,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ () => getOrchestratorMessageText(blocks, message.content), [blocks, message.content] ) - const prepareContentForCopy = useCallback( - (content: string) => - prepareCopyableMarkdown( - content, - workspaceFiles, - refreshWorkspaceFiles, - getWorkspaceResourceNames() - ), - [getWorkspaceResourceNames, refreshWorkspaceFiles, workspaceFiles] - ) - const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '') if (!hasRenderableAssistant && !trimmedContent && !isStreaming) { return null @@ -355,7 +311,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ export function MothershipChat({ workspaceId, messages: messagesProp, - workspaceFiles = EMPTY_WORKSPACE_FILES, isSending, isReconnecting = false, isLoading = false, @@ -399,12 +354,19 @@ export function MothershipChat({ const heldHighWaterRef = useRef(0) const floorChatRef = useRef(undefined) const floorDrainRafRef = useRef(0) - const refreshWorkspaceFiles = useCallback( - () => fetchFreshWorkspaceFiles(queryClient, workspaceId), - [queryClient, workspaceId] - ) - const getWorkspaceResourceNames = useCallback( - () => cachedWorkspaceResourceNames(queryClient, workspaceId), + const prepareContentForCopy = useCallback( + (content: string) => + prepareCopyableMarkdown( + content, + queryClient.getQueryData( + workspaceFilesKeys.list(workspaceId) + ) ?? EMPTY_WORKSPACE_FILES, + () => + queryClient.fetchQuery({ + ...getWorkspaceFilesQueryOptions(workspaceId), + staleTime: 0, + }) + ), [queryClient, workspaceId] ) useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) @@ -831,9 +793,7 @@ export function MothershipChat({ ) : ( /`. This is the one map @@ -45,6 +43,16 @@ const PORTABLE_KIND_TO_ID_FIELD = { */ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD +/** Serializes a portable chip link, escaping Markdown delimiters in its label. */ +export function serializePortableChipLink(kind: PortableKind, id: string, label: string): string { + const escapedLabel = label.replace(/[\\[\]]/g, '\\$&') + return `[${escapedLabel}](${CHIP_LINK_SCHEME}:${kind}/${id})` +} + +function parsePortableChipLabel(label: string): string { + return label.replace(/\\([\\[\]])/g, '$1') +} + /** * Matches a portable chip markdown link: `[label](sim:kind/id)`. * - group 1: label (plain or backslash-escaped characters) @@ -52,7 +60,7 @@ export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD * - group 3: id (any non-`)` / non-whitespace chars) */ const CHIP_LINK_PATTERN = new RegExp( - `\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${SIM_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, + `\\[((?:\\\\.|[^\\]\\\\])+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`, 'g' ) @@ -98,7 +106,7 @@ function serializeChipContext(context: ChatContext): string | null { if (!isPortableKind(context.kind)) return null const id = getPortableId(context) if (!id) return null - return toSimMarkdownLink(context.kind, id, context.label) + return serializePortableChipLink(context.kind, id, context.label) } /** @@ -206,8 +214,8 @@ export function parseChipLinks(text: string): ParsedChipLink[] { if (!isPortableKind(kind)) continue links.push({ kind, - id: fromSimHrefId(id), - label: fromSimMarkdownLabel(label), + id, + label: parsePortableChipLabel(label), start: match.index, end: match.index + full.length, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 5457a7f573f..bd1c1124890 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -43,7 +43,7 @@ import { useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' -import { fetchFreshWorkspaceFiles, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -563,7 +563,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) // handler is invoked as a void callback, so failure becomes null rather // than an unhandled rejection — and stays distinct from an empty list, so // "we could not look" is never reported as "it is not there". - const files = await fetchFreshWorkspaceFiles(queryClient, workspaceId).catch(() => null) + const files = await queryClient + .fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 }) + .catch(() => null) const resolved = files && resolveWorkspaceResourceRef(ref, files) if (resolved) { openWorkspaceResource(resolved) @@ -652,7 +654,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) { - return queryClient.fetchQuery({ - ...getWorkspaceFilesQueryOptions(workspaceId, scope), - staleTime: WORKSPACE_FILES_FORCE_REFRESH_STALE_TIME, - }) -} - /** * Hook to fetch workspace files */ diff --git a/apps/sim/lib/copilot/sim-link.ts b/apps/sim/lib/copilot/sim-link.ts deleted file mode 100644 index 4ba967c4517..00000000000 --- a/apps/sim/lib/copilot/sim-link.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** The link scheme for portable workspace resource mentions. */ -export const SIM_LINK_SCHEME = 'sim' - -/** Builds the link target for a mention of `kind`/`id`. */ -export function toSimHref(kind: string, id: string): string { - const encodedId = encodeURIComponent(id) - .replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) - .replace(/%2F/gi, '/') - return `${SIM_LINK_SCHEME}:${kind}/${encodedId}` -} - -/** 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)})` -} - -/** Restores a label serialized by {@link toSimMarkdownLink}. */ -export function fromSimMarkdownLabel(label: string): string { - return label.replace(/\\([\\[\]])/g, '$1') -} - -/** Restores an identifier serialized into a `sim:` link without throwing on malformed input. */ -export function fromSimHrefId(id: string): string { - try { - return decodeURIComponent(id) - } catch { - return id - } -}