mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(chat): show resource-type icons on inline workspace resource links (#5669)
* improvement(chat): show resource-type icons on inline workspace resource links * fix(chat): derive wsres click title from markdown label, not DOM textContent * fix(chat): keep dashed-underline affordance for unmapped wsres link types * fix(chat): parse wsres links once, derive file icons from the VFS path
This commit is contained in:
+55
-16
@@ -12,14 +12,16 @@ import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import '@sim/emcn/components/code/code.css'
|
||||
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
|
||||
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 {
|
||||
type ContentSegment,
|
||||
PendingTagIndicator,
|
||||
parseSpecialTags,
|
||||
SpecialTags,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
|
||||
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useSmoothText } from '@/hooks/use-smooth-text'
|
||||
import { sanitizeChatDisplayContent } from './chat-sanitize'
|
||||
|
||||
@@ -132,6 +134,31 @@ function appendInlineReferenceMarkdown(
|
||||
type TdProps = ComponentPropsWithoutRef<'td'>
|
||||
type ThProps = ComponentPropsWithoutRef<'th'>
|
||||
|
||||
/**
|
||||
* Maps a `#wsres-{type}-{ref}` link's resource type to the chat-context kind
|
||||
* whose icon represents it, so inline resource references render the same
|
||||
* type icon as the user-input context chips.
|
||||
*/
|
||||
const WSRES_LINK_KINDS: Record<string, ChatContextKind | undefined> = {
|
||||
workflow: 'workflow',
|
||||
table: 'table',
|
||||
file: 'file',
|
||||
}
|
||||
|
||||
/**
|
||||
* Label used to pick a file link's extension-aware document icon. The visible
|
||||
* link text can be a custom title without an extension, so prefer the file
|
||||
* name carried in the link's VFS path (its last extension-bearing segment).
|
||||
*/
|
||||
function fileIconLabel(ref: string, fallback: string): string {
|
||||
const segments = ref.split('/').filter(Boolean)
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
const decoded = decodeVfsSegmentSafe(segments[i])
|
||||
if (decoded.includes('.')) return decoded
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -202,28 +229,40 @@ const MARKDOWN_COMPONENTS = {
|
||||
},
|
||||
a({ children, href }: { children?: React.ReactNode; href?: string }) {
|
||||
if (href?.startsWith('#wsres-')) {
|
||||
const match = href.match(/^#wsres-(\w+)-(.+)$/)
|
||||
const type = match?.[1]
|
||||
const ref = match?.[2]
|
||||
const kind = type ? WSRES_LINK_KINDS[type] : undefined
|
||||
const label = extractTextContent(children)
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
|
||||
className={cn(
|
||||
'text-[var(--text-primary)]',
|
||||
kind
|
||||
? 'not-prose inline-flex items-center gap-[5px] no-underline'
|
||||
: 'underline decoration-dashed underline-offset-4'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
const match = href.match(/^#wsres-(\w+)-(.+)$/)
|
||||
if (match) {
|
||||
const type = match[1]
|
||||
const ref = match[2]
|
||||
const linkText = e.currentTarget.textContent || ref
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('wsres-click', {
|
||||
detail:
|
||||
type === 'file'
|
||||
? { type, path: ref, title: linkText }
|
||||
: { type, id: ref, title: linkText },
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!type || !ref) return
|
||||
const linkText = label || ref
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('wsres-click', {
|
||||
detail:
|
||||
type === 'file'
|
||||
? { type, path: ref, title: linkText }
|
||||
: { type, id: ref, title: linkText },
|
||||
})
|
||||
)
|
||||
}}
|
||||
>
|
||||
{kind && ref && (
|
||||
<ContextMentionIcon
|
||||
context={{ kind, label: kind === 'file' ? fileIconLabel(ref, label) : label }}
|
||||
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
|
||||
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
|
||||
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
|
||||
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
|
||||
import { decodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
|
||||
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
|
||||
|
||||
/** Respond tools are internal handoff tools shown with a friendly generic label. */
|
||||
const HIDDEN_TOOL_SUFFIX = '_respond'
|
||||
@@ -82,20 +82,6 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VFS paths store each segment percent-encoded (see {@link encodeVfsSegment}), so
|
||||
* a read on "My Report.txt" arrives as "files/My%20Report.txt". Decode for
|
||||
* display so the user sees the real file name. Falls back to the raw segment when
|
||||
* it is not valid encoding (e.g. a literal "%" that was never encoded).
|
||||
*/
|
||||
function decodeVfsSegmentSafe(segment: string): string {
|
||||
try {
|
||||
return decodeVfsSegment(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
function describeReadTarget(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ export function decodeVfsSegment(segment: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a VFS path segment for display, falling back to the raw segment when
|
||||
* it is not valid encoding (e.g. a literal "%" that was never encoded).
|
||||
*/
|
||||
export function decodeVfsSegmentSafe(segment: string): string {
|
||||
try {
|
||||
return decodeVfsSegment(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeVfsPathSegments(segments: string[]): string {
|
||||
return segments.map(encodeVfsSegment).join('/')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user