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
222 lines
7.0 KiB
TypeScript
222 lines
7.0 KiB
TypeScript
import { useMemo, type JSX, type MouseEvent } from "react";
|
|
import type { JSONContent } from "@tiptap/core";
|
|
import { renderToReactElement } from "@tiptap/static-renderer";
|
|
import { cn } from "../app/renderer/lib/utils";
|
|
import { plainTextToAgentRichTextDoc } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextDocument";
|
|
import { AGENT_RICH_TEXT_CARET_ANCHOR } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextCaretAnchor";
|
|
import { createAgentRichTextReadonlyExtensions } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextExtensions";
|
|
import { AgentMentionReadonlyView } from "../agent-gui/agentGuiNode/agentRichText/AgentMentionNodeView";
|
|
import type { AgentMessageMarkdownWorkspaceAppIcon } from "./AgentMessageMarkdown";
|
|
import type { AgentGUIProviderSkillOption } from "../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
|
|
import {
|
|
useAgentTargetPresentations,
|
|
type AgentMessageMarkdownAgentTarget
|
|
} from "./AgentTargetPresentationContext";
|
|
import { readParsedDocumentCache } from "./parsedDocumentCache";
|
|
|
|
const EMPTY_WORKSPACE_APP_ICONS: readonly AgentMessageMarkdownWorkspaceAppIcon[] =
|
|
[];
|
|
const EMPTY_SKILLS: readonly AgentGUIProviderSkillOption[] = [];
|
|
|
|
interface AgentRichTextReadonlyProps {
|
|
value: string;
|
|
documentCacheKey?: string;
|
|
className?: string;
|
|
editorClassName?: string;
|
|
onLinkClick?: (href: string) => void;
|
|
availableSkills?: readonly AgentGUIProviderSkillOption[];
|
|
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
|
|
}
|
|
|
|
export function AgentRichTextReadonly({
|
|
value,
|
|
documentCacheKey,
|
|
className,
|
|
editorClassName,
|
|
onLinkClick,
|
|
availableSkills = EMPTY_SKILLS,
|
|
workspaceAppIcons = EMPTY_WORKSPACE_APP_ICONS,
|
|
agentTargets
|
|
}: AgentRichTextReadonlyProps): JSX.Element {
|
|
"use memo";
|
|
const contextAgentTargets = useAgentTargetPresentations();
|
|
const effectiveAgentTargets = agentTargets ?? contextAgentTargets;
|
|
const skillsCacheSource = useMemo(
|
|
() => JSON.stringify(availableSkills),
|
|
[availableSkills]
|
|
);
|
|
const parsedDoc = useMemo(
|
|
() =>
|
|
readParsedDocumentCache({
|
|
namespace: "rich-text",
|
|
identity: documentCacheKey?.trim() || "content",
|
|
source: `${value}\u0000${skillsCacheSource}`,
|
|
create: () =>
|
|
plainTextToAgentRichTextDoc(value, { skills: availableSkills })
|
|
}),
|
|
[availableSkills, documentCacheKey, skillsCacheSource, value]
|
|
);
|
|
const contentDoc = useMemo(
|
|
() =>
|
|
workspaceAppIcons.length > 0
|
|
? hydrateWorkspaceAppMentionIcons(parsedDoc, workspaceAppIcons)
|
|
: parsedDoc,
|
|
[parsedDoc, workspaceAppIcons]
|
|
);
|
|
const isMentionOnly = isMentionOnlyRichTextDoc(contentDoc);
|
|
const extensions = useMemo(
|
|
() =>
|
|
createAgentRichTextReadonlyExtensions({
|
|
skills: availableSkills
|
|
}),
|
|
[availableSkills]
|
|
);
|
|
const renderedContent = useMemo(
|
|
() =>
|
|
renderToReactElement({
|
|
content: contentDoc,
|
|
extensions,
|
|
options: {
|
|
nodeMapping: {
|
|
agentFileMention: ({ node }) => (
|
|
<AgentMentionReadonlyView
|
|
agentTargets={effectiveAgentTargets}
|
|
attrs={node.attrs ?? {}}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
}),
|
|
[contentDoc, effectiveAgentTargets, extensions]
|
|
);
|
|
|
|
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
if (!onLinkClick || !(event.target instanceof Element)) {
|
|
return;
|
|
}
|
|
const mention = event.target.closest(
|
|
'[data-agent-file-mention="true"][data-agent-mention-href]'
|
|
);
|
|
if (!(mention instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const href = mention.getAttribute("data-agent-mention-href") || "";
|
|
if (!href) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onLinkClick(href);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={className}
|
|
data-agent-mention-only={isMentionOnly ? "true" : undefined}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"tiptap ProseMirror",
|
|
editorClassName,
|
|
"max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] [&_p]:m-0 [&_p]:min-h-[1.45em] [&_a[data-agent-file-mention=true]]:cursor-pointer [&_[data-agent-file-mention=true]]:overflow-hidden"
|
|
)}
|
|
onClick={handleClick}
|
|
>
|
|
{renderedContent}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isMentionOnlyRichTextDoc(doc: JSONContent): boolean {
|
|
if (doc.type !== "doc") {
|
|
return false;
|
|
}
|
|
const blocks = doc.content ?? [];
|
|
if (blocks.length !== 1) {
|
|
return false;
|
|
}
|
|
const paragraph = blocks[0];
|
|
if (paragraph?.type !== "paragraph") {
|
|
return false;
|
|
}
|
|
const inlineContent = (paragraph.content ?? []).filter(
|
|
(node) =>
|
|
!(
|
|
node.type === "text" &&
|
|
(node.text ?? "").replaceAll(AGENT_RICH_TEXT_CARET_ANCHOR, "")
|
|
.length === 0
|
|
)
|
|
);
|
|
return (
|
|
inlineContent.length === 1 && inlineContent[0]?.type === "agentFileMention"
|
|
);
|
|
}
|
|
|
|
function hydrateWorkspaceAppMentionIcons(
|
|
node: JSONContent,
|
|
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[]
|
|
): JSONContent {
|
|
const nextContent = node.content?.map((child) =>
|
|
hydrateWorkspaceAppMentionIcons(child, workspaceAppIcons)
|
|
);
|
|
if (node.type !== "agentFileMention") {
|
|
return nextContent ? { ...node, content: nextContent } : node;
|
|
}
|
|
const attrs = node.attrs ?? {};
|
|
const kind = typeof attrs.kind === "string" ? attrs.kind : "";
|
|
const isWorkspaceAppMention = kind === "workspace-app";
|
|
const isAppWorkspaceReferenceMention =
|
|
kind === "workspace-reference" && attrs.source === "app";
|
|
if (!isWorkspaceAppMention && !isAppWorkspaceReferenceMention) {
|
|
return nextContent ? { ...node, content: nextContent } : node;
|
|
}
|
|
const workspaceId =
|
|
typeof attrs.workspaceId === "string" ? attrs.workspaceId.trim() : "";
|
|
const appId =
|
|
isWorkspaceAppMention && typeof attrs.appId === "string"
|
|
? attrs.appId.trim()
|
|
: typeof attrs.targetId === "string"
|
|
? attrs.targetId.trim()
|
|
: "";
|
|
const iconUrl = resolveWorkspaceAppIconUrl({
|
|
appId,
|
|
workspaceId,
|
|
workspaceAppIcons
|
|
});
|
|
if (!iconUrl) {
|
|
return nextContent ? { ...node, content: nextContent } : node;
|
|
}
|
|
return {
|
|
...node,
|
|
attrs: {
|
|
...node.attrs,
|
|
iconUrl
|
|
},
|
|
...(nextContent ? { content: nextContent } : {})
|
|
};
|
|
}
|
|
|
|
function resolveWorkspaceAppIconUrl(input: {
|
|
appId: string;
|
|
workspaceId: string;
|
|
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[];
|
|
}): string | undefined {
|
|
if (!input.appId) {
|
|
return undefined;
|
|
}
|
|
const exactMatch = input.workspaceAppIcons.find(
|
|
(icon) =>
|
|
icon.appId.trim() === input.appId &&
|
|
(icon.workspaceId?.trim() ?? "") === input.workspaceId &&
|
|
icon.iconUrl?.trim()
|
|
);
|
|
const fallbackMatch = input.workspaceAppIcons.find(
|
|
(icon) => icon.appId.trim() === input.appId && icon.iconUrl?.trim()
|
|
);
|
|
return (
|
|
exactMatch?.iconUrl?.trim() || fallbackMatch?.iconUrl?.trim() || undefined
|
|
);
|
|
}
|