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 @@ -12,9 +12,14 @@ import type { Editor } from '@tiptap/react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

const navigation = vi.hoisted(() => ({
push: vi.fn(),
params: {} as Record<string, string>,
}))

vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useParams: () => ({}),
useRouter: () => ({ push: navigation.push }),
useParams: () => navigation.params,
}))

// Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array.
Expand All @@ -25,38 +30,59 @@ function fakeNode(attrs: Record<string, unknown>) {
return { attrs } as unknown as Parameters<typeof MentionChipView>[0]['node']
}

function fakeEditor(): Editor {
return { storage: { mentionMenu: { navigable: false } } } as unknown as Editor
function fakeEditor(navigable = false): Editor {
return { storage: { mentionMenu: { navigable } } } as unknown as Editor
}

let container: HTMLDivElement | null = null
let root: Root | null = null

async function renderChip({
kind = 'file',
id = 'f1',
label = 'notes.md',
navigable = false,
workspaceId,
}: {
kind?: string
id?: string
label?: string
navigable?: boolean
workspaceId?: string
} = {}): Promise<HTMLElement> {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
navigation.params = workspaceId ? { workspaceId } : {}
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)

await act(async () => {
root?.render(
MentionChipView({
node: fakeNode({ kind, id, label }),
editor: fakeEditor(navigable),
} as Parameters<typeof MentionChipView>[0])
)
})

const chip = container.querySelector('.mention-chip') as HTMLElement
expect(chip).not.toBeNull()
return chip
}

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
container = null
root = null
navigation.params = {}
navigation.push.mockReset()
vi.restoreAllMocks()
})

describe('MentionChipView', () => {
it('renders its wrapper with no explicit text-color utility class', async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)

await act(async () => {
root?.render(
MentionChipView({
node: fakeNode({ kind: 'file', id: 'f1', label: 'notes.md' }),
editor: fakeEditor(),
} as Parameters<typeof MentionChipView>[0])
)
})

const chip = container.querySelector('.mention-chip') as HTMLElement
expect(chip).not.toBeNull()
const chip = await renderChip()

// Any `text-*` utility targeting the wrapper itself — bare, or Tailwind's self-targeting
// `[&]:text-*` arbitrary variant (as opposed to a descendant variant like `[&>svg]:text-*`,
Expand All @@ -79,4 +105,45 @@ describe('MentionChipView', () => {
'text-[var(--text-icon)]'
)
})

it('routes an ordinary click to the canonical resource path', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const chip = await renderChip({ navigable: true, workspaceId: 'ws1' })

act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true })))

expect(navigation.push).toHaveBeenCalledOnce()
expect(navigation.push).toHaveBeenCalledWith('/workspace/ws1/files/f1')
expect(open).not.toHaveBeenCalled()
})

it.each([
['Cmd', { metaKey: true }],
['Ctrl', { ctrlKey: true }],
])('opens a %s-click in a new tab without routing the current tab', async (_name, modifier) => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const chip = await renderChip({ navigable: true, workspaceId: 'ws1' })

act(() =>
chip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...modifier }))
)

expect(open).toHaveBeenCalledOnce()
expect(open).toHaveBeenCalledWith('/workspace/ws1/files/f1', '_blank', 'noopener,noreferrer')
expect(navigation.push).not.toHaveBeenCalled()
})

it.each([
['navigation is disabled', false, 'ws1', 'file'],
['the workspace route is absent', true, undefined, 'file'],
['the resource kind is unsupported', true, 'ws1', 'integration'],
])('stays inert when %s', async (_case, navigable, workspaceId, kind) => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const chip = await renderChip({ navigable, workspaceId, kind })

act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true })))

expect(navigation.push).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ const CHIP_CLASS =

/**
* Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted
* into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays
* inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for
* integration brand icons), so it's kept out of the headless {@link MarkdownMention} module.
* into navigation (the file viewer), a click routes to the resource and Cmd/Ctrl-click opens it in a
* new tab; in a modal field it stays inert so a click can't navigate away from an unsaved edit. This
* view pulls the block registry (for integration brand icons), so it's kept out of the headless
* {@link MarkdownMention} module.
*/
export function MentionChipView({ node, editor }: ReactNodeViewProps) {
const router = useRouter()
Expand All @@ -42,8 +43,13 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) {
const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null

const handleClick = (event: MouseEvent) => {
if (!path || !(event.metaKey || event.ctrlKey)) return
if (!path) return
event.preventDefault()
event.stopPropagation()
if (event.metaKey || event.ctrlKey) {
window.open(path, '_blank', 'noopener,noreferrer')
return
}
router.push(path)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export const MENTION_PLUGIN_KEY = new PluginKey('mention')
* Per-editor storage for the `@` mention extension. The host component populates {@link store} with
* the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the
* first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no
* workspace scope) so `@` stays literal text. {@link navigable} lets a chip Cmd/Ctrl-click to its
* resource — on for the file viewer, off inside a modal field so it can't route away from an edit.
* workspace scope) so `@` stays literal text. {@link navigable} lets a chip route to its resource — on
* for the file viewer, off inside a modal field so it can't route away from an edit.
*/
export interface MentionStorage {
store: MentionStore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ describe('simLinkPath', () => {

// Each destination must match a real route — skills/folders deep-link via query params (no [id] route).
it('resolves every kind to its real in-app route', () => {
expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view')
expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1')
expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1')
expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1')
expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1')
expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1')
expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1')
})

it('encodes ids as a single route or query component', () => {
expect(simLinkPath(ws, 'file', 'f/1?tab=raw')).toBe('/workspace/ws1/files/f%2F1%3Ftab%3Draw')
expect(simLinkPath('ws/1', 'file', 'f1')).toBe('/workspace/ws%2F1/files/f1')
expect(simLinkPath(ws, 'folder', 'd/1&archived=true')).toBe(
'/workspace/ws1/files?folderId=d%2F1%26archived%3Dtrue'
)
})

it('returns null for kinds with no navigable resource (integration) and unknown kinds', () => {
// An integration mention's id is a block type, not a routable resource.
expect(simLinkPath(ws, 'integration', 'slack')).toBeNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,21 @@ export function toSimHref(kind: string, id: string): string {
* credentials), so the chip stays display-only.
*/
export function simLinkPath(workspaceId: string, kind: string, id: string): string | null {
const base = `/workspace/${workspaceId}`
const base = `/workspace/${encodeURIComponent(workspaceId)}`
const encodedId = encodeURIComponent(id)
switch (kind) {
case 'file':
return `${base}/files/${id}/view`
return `${base}/files/${encodedId}`
case 'folder':
return `${base}/files?folderId=${id}`
return `${base}/files?folderId=${encodedId}`
case 'table':
return `${base}/tables/${id}`
return `${base}/tables/${encodedId}`
case 'knowledge':
return `${base}/knowledge/${id}`
return `${base}/knowledge/${encodedId}`
case 'workflow':
return `${base}/w/${id}`
return `${base}/w/${encodedId}`
case 'skill':
return `${base}/skills?skillId=${id}`
return `${base}/skills?skillId=${encodedId}`
default:
return null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/react'
import { useMarkdownMentions } from './use-markdown-mentions'

interface UseEditorMentionsOptions {
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
/** Whether a chip can navigate to its resource. On for the file viewer, off in modal fields. */
navigable?: boolean
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
disableTagging?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ const EDITOR_SURFACE_CLASS =
*/
interface ReadOnlyPlaceholderProps {
content: JSONContent
workspaceId: string
}

function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
function ReadOnlyPlaceholder({ content, workspaceId }: ReadOnlyPlaceholderProps) {
const editor = useEditor({
extensions: EXTENSIONS,
editable: false,
Expand All @@ -113,6 +114,7 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
content,
editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } },
})
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging: true })
return <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} />
}

Expand Down Expand Up @@ -1221,7 +1223,7 @@ export function LoadedRichMarkdownEditor({
}}
/>
{showPlaceholder && placeholderContent && (
<ReadOnlyPlaceholder content={placeholderContent} />
<ReadOnlyPlaceholder content={placeholderContent} workspaceId={workspaceId} />
)}
<EditorContent
editor={editor}
Expand Down
Loading