Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
785 lines
25 KiB
TypeScript
785 lines
25 KiB
TypeScript
import {
|
|
type AnchorHTMLAttributes,
|
|
type ComponentPropsWithoutRef,
|
|
type JSX,
|
|
type MouseEvent,
|
|
useCallback,
|
|
memo,
|
|
useMemo,
|
|
useState
|
|
} from "react";
|
|
import { useTranslation } from "../i18n/index";
|
|
import { cn } from "../app/renderer/lib/utils";
|
|
import ReactMarkdown from "react-markdown";
|
|
import rehypeSanitize, {
|
|
defaultSchema,
|
|
type Options as RehypeSanitizeOptions
|
|
} from "rehype-sanitize";
|
|
import remarkGfm from "remark-gfm";
|
|
import { workspaceFileName as basenameWorkspacePath } from "@tutti-os/workspace-file-manager/services";
|
|
import { resolveWorkspaceFileExtension } from "@tutti-os/workspace-file-preview";
|
|
import { MentionPill } from "@tutti-os/ui-system/components";
|
|
import { useResolvedRichTextMention } from "@tutti-os/ui-rich-text/editor";
|
|
import {
|
|
resolveWorkspaceMentionLinkAction,
|
|
resolveWorkspaceLinkAction,
|
|
type WorkspaceLinkAction,
|
|
type WorkspaceLinkActionSource
|
|
} from "../actions/workspaceLinkActions";
|
|
import { resolveAgentWorkspaceFileVisualKind } from "./workspaceFileVisualKind";
|
|
import { stabilizeStreamingMarkdownTail } from "./streamingMarkdownTailStabilizer";
|
|
import { useStreamingVisibleText } from "./useStreamingVisibleText";
|
|
import {
|
|
useAgentTargetPresentations,
|
|
type AgentMessageMarkdownAgentTarget
|
|
} from "./AgentTargetPresentationContext";
|
|
import { resolveAgentMentionTargetPresentation } from "./agentTargetPresentation";
|
|
import {
|
|
activateMarkdownLink,
|
|
activateMarkdownLinkFromKey,
|
|
activateMarkdownLinkFromPointer,
|
|
hasOpenPotentialMermaidFence,
|
|
hashMarkdownProfilerContent,
|
|
isLikelyLongerThanLineLimit,
|
|
resolveMarkdownAnchorHref,
|
|
splitStreamingMarkdownBlocks
|
|
} from "./agentMessageMarkdownRuntime";
|
|
import {
|
|
isClickableMarkdownHref,
|
|
isLocalAbsolutePath,
|
|
isWindowsAbsolutePath,
|
|
isMentionOnlyMarkdownContent,
|
|
markdownUrlTransform,
|
|
normalizeMentionMarkdownLinks,
|
|
normalizeLocalPathMarkdownLinks,
|
|
normalizePlainIssueMentionTitleContent,
|
|
normalizePlainSessionMentionTitle,
|
|
parseMentionLink,
|
|
resolveMarkdownWorkspaceMediaPath,
|
|
resolveRenderableMarkdownMediaSrc,
|
|
type ParsedMentionLink
|
|
} from "./agentMessageMarkdownLinks";
|
|
import { MarkdownLinkContext } from "./agentMessageMarkdownContext";
|
|
import {
|
|
MarkdownCode,
|
|
MarkdownListItem,
|
|
MarkdownOrderedList,
|
|
MarkdownParagraph,
|
|
MarkdownPre,
|
|
MarkdownUnorderedList,
|
|
textFromReactNode
|
|
} from "./AgentMessageMarkdownRenderers";
|
|
import { MarkdownMedia } from "./AgentMessageMarkdownMedia";
|
|
import { remarkLiteralAutolinkBoundary } from "./remarkLiteralAutolinkBoundary";
|
|
import { cachedMarkdownParser } from "./cachedMarkdownParser";
|
|
export { resetCachedMarkdownImagesForTests } from "./AgentMessageMarkdownMedia";
|
|
export type { StreamingMarkdownBlock } from "./agentMessageMarkdownRuntime";
|
|
export { splitStreamingMarkdownBlocks } from "./agentMessageMarkdownRuntime";
|
|
|
|
const STREAMING_MARKDOWN_FRAME_MS = 24;
|
|
const STREAMING_MARKDOWN_MAX_CHARS_PER_SECOND = 6_000;
|
|
const STREAMING_MARKDOWN_TAIL_FLUSH_CHARS = 0;
|
|
const STANDARD_MARKDOWN_LINK_PROTOCOLS = [
|
|
"http",
|
|
"https",
|
|
"irc",
|
|
"ircs",
|
|
"mailto",
|
|
"tel",
|
|
"xmpp"
|
|
] as const;
|
|
const WINDOWS_DRIVE_HREF_PROTOCOLS =
|
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
export const AGENT_MARKDOWN_PLAIN_TITLE_CLASSNAME =
|
|
"[font-size:inherit] [line-height:inherit] text-inherit [&_a]:text-inherit [&_a]:font-inherit [&_a]:no-underline [&_a:hover]:no-underline [&_a:focus-visible]:no-underline [&_strong]:font-inherit [&_strong]:text-inherit";
|
|
|
|
const MARKDOWN_SANITIZE_SCHEMA: RehypeSanitizeOptions = {
|
|
...defaultSchema,
|
|
protocols: {
|
|
...defaultSchema.protocols,
|
|
href: [
|
|
...(defaultSchema.protocols?.href ?? []),
|
|
"mention",
|
|
...STANDARD_MARKDOWN_LINK_PROTOCOLS,
|
|
...WINDOWS_DRIVE_HREF_PROTOCOLS
|
|
],
|
|
src: [...(defaultSchema.protocols?.src ?? []), "file"]
|
|
}
|
|
};
|
|
|
|
interface MarkdownHastNode {
|
|
type?: string;
|
|
properties?: Record<string, unknown>;
|
|
children?: MarkdownHastNode[];
|
|
}
|
|
|
|
function normalizeWindowsMarkdownMediaPaths() {
|
|
return (tree: MarkdownHastNode): void => {
|
|
normalizeWindowsMarkdownMediaPathNode(tree);
|
|
};
|
|
}
|
|
|
|
function normalizeWindowsMarkdownMediaPathNode(node: MarkdownHastNode): void {
|
|
if (node.type === "element" && typeof node.properties?.src === "string") {
|
|
const src = node.properties.src;
|
|
const workspacePath = resolveMarkdownWorkspaceMediaPath(src);
|
|
if (workspacePath && isWindowsAbsolutePath(workspacePath)) {
|
|
node.properties.src = resolveRenderableMarkdownMediaSrc(src);
|
|
}
|
|
}
|
|
for (const child of node.children ?? []) {
|
|
normalizeWindowsMarkdownMediaPathNode(child);
|
|
}
|
|
}
|
|
|
|
export interface AgentMessageMarkdownWorkspaceLinkContext {
|
|
workspaceRoot?: string | null;
|
|
basePath?: string | null;
|
|
source: WorkspaceLinkActionSource;
|
|
}
|
|
|
|
interface AgentMessageMarkdownProps {
|
|
content: string;
|
|
documentCacheKey?: string;
|
|
onLinkClick?: (href: string) => void;
|
|
onLinkAction?: (action: WorkspaceLinkAction) => void;
|
|
workspaceLinkContext?: AgentMessageMarkdownWorkspaceLinkContext | null;
|
|
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
|
|
collapsible?: boolean;
|
|
expandLabel?: string;
|
|
className?: string;
|
|
inline?: boolean;
|
|
normalizePlainIssueMentionTitle?: boolean;
|
|
enableImageZoom?: boolean;
|
|
streaming?: boolean;
|
|
}
|
|
|
|
export interface AgentMessageMarkdownWorkspaceAppIcon {
|
|
appId: string;
|
|
iconUrl: string | null;
|
|
workspaceId?: string | null;
|
|
}
|
|
|
|
const EMPTY_WORKSPACE_APP_ICONS: readonly AgentMessageMarkdownWorkspaceAppIcon[] =
|
|
[];
|
|
const EMPTY_AGENT_TARGETS: readonly AgentMessageMarkdownAgentTarget[] = [];
|
|
|
|
export type MarkdownDomProps<Tag extends keyof JSX.IntrinsicElements> =
|
|
ComponentPropsWithoutRef<Tag> & {
|
|
node?: unknown;
|
|
};
|
|
|
|
type ReactMarkdownComponents = ComponentPropsWithoutRef<
|
|
typeof ReactMarkdown
|
|
>["components"];
|
|
type ReactMarkdownRemarkPlugins = NonNullable<
|
|
ComponentPropsWithoutRef<typeof ReactMarkdown>["remarkPlugins"]
|
|
>;
|
|
|
|
export function AgentMessageMarkdown({
|
|
content,
|
|
documentCacheKey,
|
|
onLinkClick,
|
|
onLinkAction,
|
|
workspaceLinkContext = null,
|
|
workspaceAppIcons = EMPTY_WORKSPACE_APP_ICONS,
|
|
agentTargets,
|
|
collapsible = false,
|
|
expandLabel,
|
|
className,
|
|
inline = false,
|
|
normalizePlainIssueMentionTitle = false,
|
|
enableImageZoom = false,
|
|
streaming = false
|
|
}: AgentMessageMarkdownProps): JSX.Element {
|
|
"use memo";
|
|
const { t } = useTranslation();
|
|
const contextAgentTargets = useAgentTargetPresentations();
|
|
const effectiveAgentTargets = agentTargets ?? contextAgentTargets;
|
|
const visibleContent = useStreamingVisibleText(content, {
|
|
enabled: streaming,
|
|
frameMs: STREAMING_MARKDOWN_FRAME_MS,
|
|
maxCharsPerSecond: STREAMING_MARKDOWN_MAX_CHARS_PER_SECOND,
|
|
trailingFlushChars: STREAMING_MARKDOWN_TAIL_FLUSH_CHARS
|
|
});
|
|
const stabilizedContent = useMemo(
|
|
() =>
|
|
stabilizeStreamingMarkdownTail(visibleContent, {
|
|
streaming
|
|
}).content,
|
|
[streaming, visibleContent]
|
|
);
|
|
const mermaidStreaming =
|
|
streaming || hasOpenPotentialMermaidFence(visibleContent);
|
|
const workspaceRoot = workspaceLinkContext?.workspaceRoot ?? null;
|
|
const basePath = workspaceLinkContext?.basePath ?? null;
|
|
const workspaceLinkSource = workspaceLinkContext?.source ?? null;
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const resolvedExpandLabel =
|
|
expandLabel ?? t("agentHost.workspaceAgentMessageExpand");
|
|
const shouldCollapse =
|
|
collapsible && isLikelyLongerThanLineLimit(stabilizedContent);
|
|
const isCollapsed = shouldCollapse && !isExpanded;
|
|
const ContainerTag = inline ? "span" : "div";
|
|
const normalizedContent = useMemo(
|
|
() =>
|
|
normalizeLocalPathMarkdownLinks(
|
|
normalizeMentionMarkdownLinks(
|
|
normalizePlainIssueMentionTitle
|
|
? normalizePlainIssueMentionTitleContent(
|
|
normalizePlainSessionMentionTitle(stabilizedContent)
|
|
)
|
|
: normalizePlainSessionMentionTitle(stabilizedContent)
|
|
)
|
|
),
|
|
[normalizePlainIssueMentionTitle, stabilizedContent]
|
|
);
|
|
const settledRemarkPlugins = useMemo<ReactMarkdownRemarkPlugins>(
|
|
() => [
|
|
remarkGfm,
|
|
remarkLiteralAutolinkBoundary,
|
|
[
|
|
cachedMarkdownParser,
|
|
{
|
|
cacheKey: documentCacheKey?.trim() || "content",
|
|
content: normalizedContent
|
|
}
|
|
]
|
|
],
|
|
[documentCacheKey, normalizedContent]
|
|
);
|
|
const isMentionOnly = isMentionOnlyMarkdownContent(normalizedContent);
|
|
const handleLinkClick = useCallback(
|
|
(href: string): void => {
|
|
if (workspaceLinkSource && onLinkAction) {
|
|
const action =
|
|
resolveWorkspaceMentionLinkAction({
|
|
href,
|
|
source: workspaceLinkSource
|
|
}) ??
|
|
(workspaceRoot || basePath
|
|
? resolveWorkspaceLinkAction({
|
|
href,
|
|
workspaceRoot,
|
|
basePath,
|
|
source: workspaceLinkSource
|
|
})
|
|
: null);
|
|
if (action) {
|
|
onLinkAction(action);
|
|
return;
|
|
}
|
|
}
|
|
onLinkClick?.(href);
|
|
},
|
|
[basePath, onLinkAction, onLinkClick, workspaceLinkSource, workspaceRoot]
|
|
);
|
|
const handleAnchorClickCapture = useCallback(
|
|
(event: MouseEvent<HTMLElement>): void => {
|
|
const href = resolveMarkdownAnchorHref(event.target);
|
|
if (!href) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleLinkClick(href);
|
|
},
|
|
[handleLinkClick]
|
|
);
|
|
const markdownComponents = useMemo(
|
|
() => ({
|
|
a: (props: MarkdownDomProps<"a">) => (
|
|
<MarkdownLink
|
|
{...props}
|
|
onLinkClick={handleLinkClick}
|
|
workspaceAppIcons={workspaceAppIcons}
|
|
agentTargets={effectiveAgentTargets}
|
|
/>
|
|
),
|
|
code: (props: MarkdownDomProps<"code">) => <MarkdownCode {...props} />,
|
|
img: (props: MarkdownDomProps<"img">) => (
|
|
<MarkdownMedia {...props} enableZoom={enableImageZoom} />
|
|
),
|
|
p: (props: MarkdownDomProps<"p">) => (
|
|
<MarkdownParagraph {...props} inline={inline} />
|
|
),
|
|
ul: MarkdownUnorderedList,
|
|
ol: MarkdownOrderedList,
|
|
li: MarkdownListItem,
|
|
pre: (props: MarkdownDomProps<"pre">) => (
|
|
<MarkdownPre
|
|
{...props}
|
|
mermaidEnabled={!inline}
|
|
streaming={mermaidStreaming}
|
|
/>
|
|
)
|
|
}),
|
|
[
|
|
effectiveAgentTargets,
|
|
enableImageZoom,
|
|
handleLinkClick,
|
|
inline,
|
|
mermaidStreaming,
|
|
workspaceAppIcons
|
|
]
|
|
);
|
|
|
|
return (
|
|
<ContainerTag
|
|
className="flex w-full min-w-0 flex-col items-start gap-1"
|
|
data-workspace-agent-markdown-shell="true"
|
|
>
|
|
<ContainerTag
|
|
className={cn(
|
|
"relative w-full min-w-0 overflow-x-auto text-[13px] leading-[1.5] text-[var(--text-primary)] [overflow-wrap:anywhere]",
|
|
"[&_>table:first-child]:mt-0 [&_p]:mb-2 [&_pre]:mb-2 [&_blockquote]:mb-2",
|
|
"[&_hr]:my-4 [&_hr]:h-0 [&_hr]:border-0 [&_hr]:border-t [&_hr]:border-t-[color-mix(in_srgb,var(--text-primary)_14%,transparent)]",
|
|
"[&_ul]:my-2 [&_ul]:max-w-full [&_ul]:rounded-[8px] [&_ul]:border [&_ul]:border-[var(--line-2)] [&_ul]:bg-[var(--background-panel)] [&_ul]:px-4 [&_ul]:py-2",
|
|
"[&_ol]:my-2 [&_ol]:max-w-full [&_ol]:rounded-[8px] [&_ol]:border [&_ol]:border-[var(--line-2)] [&_ol]:bg-[var(--background-panel)] [&_ol]:px-4 [&_ol]:py-2",
|
|
"[&_li>ul]:mt-1.5 [&_li>ul]:mb-0.5 [&_li>ul]:border-0 [&_li>ul]:px-0 [&_li>ul]:py-1",
|
|
"[&_li>ol]:mt-1.5 [&_li>ol]:mb-0.5 [&_li>ol]:border-0 [&_li>ol]:px-0 [&_li>ol]:py-1",
|
|
"[&_table]:my-2 [&_table]:w-max [&_table]:min-w-full [&_table]:max-w-full [&_table]:border-separate [&_table]:border-spacing-0 [&_table]:overflow-hidden [&_table]:rounded-[8px] [&_table]:border [&_table]:border-[var(--line-2)] [&_table]:text-[13px] [&_table]:leading-[1.45]",
|
|
"[&_th]:max-w-[280px] [&_th]:border-r [&_th]:border-b [&_th]:border-[var(--line-2)] [&_th]:px-2 [&_th]:py-1.5 [&_th]:align-top [&_th]:font-semibold [&_th]:text-[var(--text-primary)] [&_th]:[overflow-wrap:anywhere] [&_th]:bg-[color-mix(in_srgb,var(--background-panel)_94%,var(--text-primary))]",
|
|
"[&_td]:max-w-[280px] [&_td]:border-r [&_td]:border-b [&_td]:border-[var(--line-2)] [&_td]:px-2 [&_td]:py-1.5 [&_td]:align-top [&_td]:[overflow-wrap:anywhere]",
|
|
"[&_tr:last-child_th]:border-b-0 [&_tr:last-child_td]:border-b-0 [&_th:last-child]:border-r-0 [&_td:last-child]:border-r-0",
|
|
"[&_a]:cursor-pointer [&_a]:font-semibold [&_a]:text-[var(--tutti-purple)] [&_a]:no-underline [&_a:hover]:underline [&_a:focus-visible]:underline",
|
|
"[&_strong]:font-semibold",
|
|
"[&_code]:inline [&_code]:rounded-[2px] [&_code]:bg-[var(--transparency-block)] [&_code]:px-1 [&_code]:py-[1px] [&_code]:font-[var(--tsh-font-mono)] [&_code]:text-[11px] [&_code]:leading-[1.35] [&_code]:text-[var(--text-primary)] [&_code]:[box-decoration-break:clone] [&_code]:[-webkit-box-decoration-break:clone] [&_code]:[overflow-wrap:anywhere] [&_code]:[word-break:break-word]",
|
|
"[&_pre]:box-border [&_pre]:overflow-auto [&_pre]:rounded-[6px] [&_pre]:bg-[var(--transparency-block)] [&_pre]:px-2.5 [&_pre]:py-2",
|
|
"[&_pre_code]:inline [&_pre_code]:h-auto [&_pre_code]:items-normal [&_pre_code]:rounded-none [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[inherit] [&_pre_code]:leading-[inherit] [&_pre_code]:[white-space:pre-wrap] [&_pre_code]:[overflow-wrap:anywhere] [&_pre_code]:[word-break:break-word]",
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
inline &&
|
|
"inline min-w-0 overflow-hidden align-baseline [&_p]:inline [&_p]:m-0",
|
|
shouldCollapse &&
|
|
"overflow-hidden transition-[max-height] duration-220 ease-out",
|
|
isCollapsed &&
|
|
"max-h-[calc(13px*1.5*8)] overflow-hidden [mask-image:linear-gradient(180deg,black_0%,black_calc(100%_-_36px),transparent_100%)] [-webkit-mask-image:linear-gradient(180deg,black_0%,black_calc(100%_-_36px),transparent_100%)] [&_pre]:overflow-hidden",
|
|
shouldCollapse && !isCollapsed && "max-h-[72rem]",
|
|
className
|
|
)}
|
|
data-workspace-agent-markdown="true"
|
|
data-agent-mention-only={isMentionOnly ? "true" : undefined}
|
|
data-collapsed={isCollapsed ? "true" : "false"}
|
|
onClickCapture={handleAnchorClickCapture}
|
|
>
|
|
{streaming ? (
|
|
<StreamingMarkdownBlocks
|
|
content={normalizedContent}
|
|
components={markdownComponents}
|
|
/>
|
|
) : (
|
|
<ReactMarkdown
|
|
remarkPlugins={settledRemarkPlugins}
|
|
rehypePlugins={[
|
|
normalizeWindowsMarkdownMediaPaths,
|
|
[rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]
|
|
]}
|
|
urlTransform={markdownUrlTransform}
|
|
components={markdownComponents}
|
|
>
|
|
{normalizedContent}
|
|
</ReactMarkdown>
|
|
)}
|
|
</ContainerTag>
|
|
{shouldCollapse && !isExpanded ? (
|
|
<button
|
|
type="button"
|
|
className="m-0 border-0 bg-transparent p-0 text-[11px] leading-[1.4] font-semibold text-[var(--tutti-purple)] hover:underline focus-visible:underline focus-visible:outline-none"
|
|
onClick={() => setIsExpanded(true)}
|
|
>
|
|
{resolvedExpandLabel}
|
|
</button>
|
|
) : null}
|
|
</ContainerTag>
|
|
);
|
|
}
|
|
|
|
function StreamingMarkdownBlocks({
|
|
content,
|
|
components
|
|
}: {
|
|
content: string;
|
|
components: ReactMarkdownComponents;
|
|
}): JSX.Element {
|
|
const blocks = useMemo(
|
|
() => splitStreamingMarkdownBlocks(content),
|
|
[content]
|
|
);
|
|
return (
|
|
<>
|
|
{blocks.map((block, index) => (
|
|
<MemoizedMarkdownBlock
|
|
key={`${index}:${hashMarkdownProfilerContent(block.initialKeyContent)}`}
|
|
content={block.content}
|
|
components={components}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const MemoizedMarkdownBlock = memo(function MemoizedMarkdownBlock({
|
|
content,
|
|
components
|
|
}: {
|
|
content: string;
|
|
components: ReactMarkdownComponents;
|
|
}): JSX.Element {
|
|
return (
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm, remarkLiteralAutolinkBoundary]}
|
|
rehypePlugins={[
|
|
normalizeWindowsMarkdownMediaPaths,
|
|
[rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]
|
|
]}
|
|
urlTransform={markdownUrlTransform}
|
|
components={components}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
);
|
|
});
|
|
|
|
function MarkdownLink({
|
|
node: _node,
|
|
onClick: _onClick,
|
|
onLinkClick,
|
|
workspaceAppIcons,
|
|
agentTargets,
|
|
href,
|
|
...props
|
|
}: MarkdownDomProps<"a"> & {
|
|
onLinkClick?: (href: string) => void;
|
|
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
|
|
}): JSX.Element {
|
|
"use memo";
|
|
const { t } = useTranslation();
|
|
const targetHref = href?.trim() ?? "";
|
|
const mention = targetHref
|
|
? parseMentionLink(
|
|
targetHref,
|
|
textFromReactNode(props.children),
|
|
workspaceAppIcons ?? [],
|
|
agentTargets ?? EMPTY_AGENT_TARGETS,
|
|
t("agentHost.agentGui.workspaceAppFactoryMentionFallback")
|
|
)
|
|
: null;
|
|
if (mention) {
|
|
return (
|
|
<MentionLink
|
|
{...props}
|
|
agentTargets={agentTargets ?? EMPTY_AGENT_TARGETS}
|
|
href={targetHref}
|
|
mention={mention}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
);
|
|
}
|
|
const fileMention = targetHref
|
|
? parseWorkspaceFileMentionLink(
|
|
targetHref,
|
|
textFromReactNode(props.children)
|
|
)
|
|
: null;
|
|
if (fileMention) {
|
|
return (
|
|
<WorkspaceFileMentionLink
|
|
{...props}
|
|
href={targetHref}
|
|
mention={fileMention}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
);
|
|
}
|
|
if (!isClickableMarkdownHref(targetHref)) {
|
|
return (
|
|
<MarkdownLinkContext.Provider value={true}>
|
|
<span className={props.className} title={props.title}>
|
|
{props.children}
|
|
</span>
|
|
</MarkdownLinkContext.Provider>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MarkdownLinkContext.Provider value={true}>
|
|
<a
|
|
{...props}
|
|
data-agent-link-href={targetHref}
|
|
role="link"
|
|
tabIndex={0}
|
|
onClick={(event) => {
|
|
activateMarkdownLink(event, targetHref, onLinkClick);
|
|
}}
|
|
onPointerDown={(event) => {
|
|
activateMarkdownLinkFromPointer(event, targetHref, onLinkClick);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
activateMarkdownLinkFromKey(event, targetHref, onLinkClick);
|
|
}}
|
|
/>
|
|
</MarkdownLinkContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface ParsedWorkspaceFileMentionLink {
|
|
label: string;
|
|
href: string;
|
|
entryKind: "file" | "directory";
|
|
visualKind: string;
|
|
}
|
|
|
|
interface ParsedWorkspaceFileMentionTarget {
|
|
path: string;
|
|
explicitKind: "file" | "directory" | null;
|
|
}
|
|
|
|
function parseWorkspaceFileMentionTarget(
|
|
href: string
|
|
): ParsedWorkspaceFileMentionTarget | null {
|
|
const rawTarget = href.trim();
|
|
const target = /^\/[A-Za-z]:[\\/]/.test(rawTarget)
|
|
? rawTarget.slice(1)
|
|
: rawTarget;
|
|
if (isWindowsAbsolutePath(target)) {
|
|
const path = target.replace(/[\\/]+$/, "");
|
|
if (!path) {
|
|
return null;
|
|
}
|
|
return {
|
|
path,
|
|
explicitKind: /[\\/]$/.test(target) ? "directory" : null
|
|
};
|
|
}
|
|
if (!isLocalAbsolutePath(target)) {
|
|
return null;
|
|
}
|
|
|
|
let url: URL;
|
|
try {
|
|
url = new URL(target, "https://tsh.local");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const explicitKindValue =
|
|
url.searchParams.get("kind") ??
|
|
url.searchParams.get("refType") ??
|
|
url.searchParams.get("entryKind") ??
|
|
"";
|
|
const normalizedKind = explicitKindValue.trim().toLowerCase();
|
|
const explicitKind =
|
|
normalizedKind === "folder" || normalizedKind === "directory"
|
|
? "directory"
|
|
: normalizedKind === "file"
|
|
? "file"
|
|
: null;
|
|
const path = url.pathname.replace(/\/+$/, "");
|
|
if (!path || path === "/") {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path,
|
|
explicitKind:
|
|
explicitKind ?? (url.pathname.endsWith("/") ? "directory" : null)
|
|
};
|
|
}
|
|
|
|
function resolveWorkspaceFileMentionEntryKind(
|
|
path: string,
|
|
label: string,
|
|
explicitKind: "file" | "directory" | null
|
|
): "file" | "directory" {
|
|
if (explicitKind) {
|
|
return explicitKind;
|
|
}
|
|
if (resolveWorkspaceFileExtension(path)) {
|
|
return "file";
|
|
}
|
|
|
|
const basename = basenameWorkspacePath(path);
|
|
return basename === label ? "directory" : "file";
|
|
}
|
|
|
|
function parseWorkspaceFileMentionLink(
|
|
href: string,
|
|
rawLabel: string
|
|
): ParsedWorkspaceFileMentionLink | null {
|
|
const label = rawLabel.trim();
|
|
const target = parseWorkspaceFileMentionTarget(href);
|
|
if (!target || !label.startsWith("@")) {
|
|
return null;
|
|
}
|
|
const fileLabel = label.replace(/^@+/, "").trim();
|
|
if (!fileLabel) {
|
|
return null;
|
|
}
|
|
const entryKind = resolveWorkspaceFileMentionEntryKind(
|
|
target.path,
|
|
fileLabel,
|
|
target.explicitKind
|
|
);
|
|
return {
|
|
label: fileLabel,
|
|
href: target.path,
|
|
entryKind,
|
|
visualKind: resolveAgentWorkspaceFileVisualKind(target.path, {
|
|
refType: entryKind === "directory" ? "folder" : "file"
|
|
})
|
|
};
|
|
}
|
|
|
|
function WorkspaceFileMentionLink({
|
|
onClick: _onClick,
|
|
onLinkClick,
|
|
href: _href,
|
|
mention,
|
|
...props
|
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
href: string;
|
|
mention: ParsedWorkspaceFileMentionLink;
|
|
onLinkClick?: (href: string) => void;
|
|
}): JSX.Element {
|
|
"use memo";
|
|
return (
|
|
<a
|
|
{...props}
|
|
className={cn(
|
|
"tsh-workspace-file-link tsh-agent-object-token tsh-agent-object-token--file",
|
|
props.className
|
|
)}
|
|
data-agent-file-mention="true"
|
|
data-agent-mention-kind="file"
|
|
data-agent-file-entry-kind={mention.entryKind}
|
|
data-agent-file-visual-kind={mention.visualKind}
|
|
data-agent-link-href={mention.href}
|
|
data-agent-mention-href={mention.href}
|
|
aria-label={mention.label}
|
|
role="link"
|
|
tabIndex={0}
|
|
onClick={(event) => {
|
|
activateMarkdownLink(event, mention.href, onLinkClick);
|
|
}}
|
|
onPointerDown={(event) => {
|
|
activateMarkdownLinkFromPointer(event, mention.href, onLinkClick);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
activateMarkdownLinkFromKey(event, mention.href, onLinkClick);
|
|
}}
|
|
>
|
|
<span className="tsh-agent-object-token__icon" aria-hidden="true" />
|
|
<span className="tsh-agent-object-token__main">{mention.label}</span>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function MentionLink({
|
|
agentTargets,
|
|
onClick: _onClick,
|
|
onLinkClick,
|
|
href,
|
|
mention,
|
|
...props
|
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
agentTargets: readonly AgentMessageMarkdownAgentTarget[];
|
|
href: string;
|
|
mention: ParsedMentionLink;
|
|
onLinkClick?: (href: string) => void;
|
|
}): JSX.Element {
|
|
"use memo";
|
|
const snapshot = useResolvedRichTextMention({
|
|
providerId: mention.providerId,
|
|
entityId: mention.entityId,
|
|
label: mention.label,
|
|
scope: mention.scope
|
|
});
|
|
const resolved = snapshot.state === "ready" ? snapshot.resolved : undefined;
|
|
const presentation = resolved?.presentation;
|
|
const fallbackIconUrl =
|
|
presentation?.iconUrl?.trim() ||
|
|
presentation?.thumbnailUrl?.trim() ||
|
|
presentation?.agentIconUrl?.trim() ||
|
|
mention.iconUrl;
|
|
const fallbackLabel = resolved?.label?.trim() || mention.label;
|
|
const agentTargetPresentation =
|
|
mention.kind === "agent-target" || mention.kind === "session"
|
|
? resolveAgentMentionTargetPresentation({
|
|
agentTargetId:
|
|
mention.kind === "agent-target"
|
|
? mention.entityId
|
|
: mention.scope?.agentTargetId,
|
|
agentTargets,
|
|
fallbackIconUrl,
|
|
fallbackName: fallbackLabel,
|
|
fallbackProvider:
|
|
presentation?.agentProviderId?.trim() || mention.agentProviderId,
|
|
workspaceId: mention.scope?.workspaceId
|
|
})
|
|
: null;
|
|
const label =
|
|
mention.kind === "agent-target"
|
|
? (agentTargetPresentation?.name ?? fallbackLabel)
|
|
: fallbackLabel;
|
|
const iconUrl = agentTargetPresentation?.iconUrl ?? fallbackIconUrl;
|
|
const pillKind =
|
|
mention.kind === "workspace-issue" || mention.referenceSource === "task"
|
|
? "issue"
|
|
: mention.kind === "session" || mention.kind === "agent-target"
|
|
? "session"
|
|
: mention.kind === "pasted-text"
|
|
? "file"
|
|
: "app";
|
|
const usesSessionIcon = mention.kind === "session";
|
|
|
|
return (
|
|
<a
|
|
{...props}
|
|
className={cn(
|
|
"tsh-agent-object-token tsh-agent-object-token--entity",
|
|
props.className
|
|
)}
|
|
data-agent-file-mention="true"
|
|
data-agent-link-href={href}
|
|
data-agent-mention-icon-url={iconUrl}
|
|
data-agent-mention-href={href}
|
|
data-agent-mention-kind={mention.kind}
|
|
data-agent-reference-source={mention.referenceSource}
|
|
aria-label={label}
|
|
role="link"
|
|
tabIndex={0}
|
|
onClick={(event) => {
|
|
activateMarkdownLink(event, href, onLinkClick);
|
|
}}
|
|
onPointerDown={(event) => {
|
|
activateMarkdownLinkFromPointer(event, href, onLinkClick);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
activateMarkdownLinkFromKey(event, href, onLinkClick);
|
|
}}
|
|
>
|
|
<MentionPill
|
|
className="top-0 max-w-full"
|
|
iconUrl={iconUrl}
|
|
iconContainerProps={{
|
|
className: "h-4 w-4",
|
|
"data-agent-mention-app-icon": usesSessionIcon ? undefined : "true",
|
|
"data-agent-mention-session-icon": usesSessionIcon
|
|
? "true"
|
|
: undefined,
|
|
"data-workspace-app-icon": usesSessionIcon ? undefined : "true"
|
|
}}
|
|
fallbackIconProps={{
|
|
"data-agent-mention-fallback-icon": "true"
|
|
}}
|
|
kind={pillKind}
|
|
label={<span className="tsh-agent-object-token__main">{label}</span>}
|
|
removable={false}
|
|
tooltipEnabled
|
|
withTooltipProvider
|
|
/>
|
|
</a>
|
|
);
|
|
}
|