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:
Waleed
2026-07-14 15:51:08 -07:00
committed by GitHub
parent 103ff7cfe8
commit 82bed774e5
3 changed files with 68 additions and 31 deletions
@@ -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
+12
View File
@@ -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('/')
}