fix: use opencode skills as source of truth
This commit is contained in:
@@ -18,6 +18,7 @@ import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUt
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import ReasoningPart from './ReasoningPart';
|
||||
import JustificationBlock from './JustificationBlock';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
@@ -599,7 +600,9 @@ const StaticToolRowInner: React.FC<{
|
||||
const isReadGroup = toolName.toLowerCase() === 'read';
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
|
||||
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
||||
|
||||
const descriptions = React.useMemo(() => {
|
||||
const descs: string[] = [];
|
||||
@@ -648,6 +651,15 @@ const StaticToolRowInner: React.FC<{
|
||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||
}, [currentDirectory, runtime]);
|
||||
|
||||
const handleSkillClick = React.useCallback((skillName: string) => {
|
||||
const skill = skillByName.get(skillName);
|
||||
if (!skill?.path) {
|
||||
return;
|
||||
}
|
||||
const uiStore = useUIStore.getState();
|
||||
uiStore.openContextFile(currentDirectory || getContextDirectoryForPath('', skill.path), skill.path);
|
||||
}, [currentDirectory, skillByName]);
|
||||
|
||||
const normalizedToolName = toolName.toLowerCase();
|
||||
const isSearchGroup = normalizedToolName === 'grep'
|
||||
|| normalizedToolName === 'search'
|
||||
@@ -655,6 +667,7 @@ const StaticToolRowInner: React.FC<{
|
||||
|| normalizedToolName === 'ripgrep'
|
||||
|| normalizedToolName === 'glob';
|
||||
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
|
||||
const isSkillGroup = normalizedToolName === 'skill';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -725,7 +738,25 @@ const StaticToolRowInner: React.FC<{
|
||||
</a>
|
||||
))
|
||||
: null}
|
||||
{!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? (
|
||||
{isSkillGroup && descriptions.length > 0
|
||||
? descriptions.map((skillName, index) => (
|
||||
<button
|
||||
key={`${skillName}-${index}`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleSkillClick(skillName);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5 text-left hover:opacity-90"
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={skillName}
|
||||
>
|
||||
{skillName}
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{!isReadGroup && !isSearchGroup && !isFetchGroup && !isSkillGroup && descriptions.length > 0 ? (
|
||||
<Text
|
||||
variant={animateTailText ? 'generate-effect' : 'static'}
|
||||
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { AgentMentionInfo } from '../types';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -20,6 +22,8 @@ const buildMentionUrl = (name: string): string => {
|
||||
return `https://opencode.ai/docs/agents/#${encoded}`;
|
||||
};
|
||||
|
||||
const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
|
||||
|
||||
const escapeHtml = (text: string): string => {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
@@ -41,8 +45,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const openContextFile = useUIStore((state) => state.openContextFile);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
||||
|
||||
const openSkill = React.useCallback((name: string) => {
|
||||
const skill = skillByName.get(name);
|
||||
if (!skill?.path) return;
|
||||
openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path);
|
||||
}, [effectiveDirectory, openContextFile, skillByName]);
|
||||
|
||||
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -76,7 +90,17 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [textContent, isExpanded]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const skillLink = target?.closest<HTMLElement>('[data-skill-name]');
|
||||
const skillName = skillLink?.dataset.skillName;
|
||||
if (skillName) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openSkill(skillName);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = textRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -89,7 +113,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
if (!isExpanded && isTruncated) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasActiveSelectionInElement, isExpanded, isTruncated]);
|
||||
}, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
|
||||
|
||||
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -108,21 +132,62 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
content = content.replace(agentMention.token, mentionHtml);
|
||||
}
|
||||
|
||||
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => {
|
||||
const slashIndex = offset + prefix.length;
|
||||
if (slashIndex === 0 || !skillByName.has(skillName)) return match;
|
||||
return `${prefix}<a href="#" data-skill-name="${skillName}" class="text-primary hover:underline">/${skillName}</a>`;
|
||||
});
|
||||
|
||||
return content;
|
||||
}, [agentMention, textContent]);
|
||||
}, [agentMention, skillByName, textContent]);
|
||||
|
||||
const plainTextContent = React.useMemo(() => {
|
||||
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||
return textContent;
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let agentMentionUsed = false;
|
||||
let match: RegExpExecArray | null;
|
||||
SKILL_TOKEN_PATTERN.lastIndex = 0;
|
||||
|
||||
while ((match = SKILL_TOKEN_PATTERN.exec(textContent)) !== null) {
|
||||
const prefix = match[1] || '';
|
||||
const skillName = match[2];
|
||||
const slashIndex = match.index + prefix.length;
|
||||
if (slashIndex === 0 || !skillByName.has(skillName)) continue;
|
||||
|
||||
if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index));
|
||||
if (prefix) nodes.push(prefix);
|
||||
nodes.push(
|
||||
<button
|
||||
key={`skill-${slashIndex}-${skillName}`}
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openSkill(skillName);
|
||||
}}
|
||||
>
|
||||
/{skillName}
|
||||
</button>
|
||||
);
|
||||
cursor = slashIndex + skillName.length + 1;
|
||||
}
|
||||
|
||||
const idx = textContent.indexOf(agentMention.token);
|
||||
const before = textContent.slice(0, idx);
|
||||
const after = textContent.slice(idx + agentMention.token.length);
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
if (cursor < textContent.length) nodes.push(textContent.slice(cursor));
|
||||
|
||||
const withSkills = nodes.length > 0 ? nodes : [textContent];
|
||||
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||
return withSkills;
|
||||
}
|
||||
|
||||
return withSkills.flatMap((node, index) => {
|
||||
if (agentMentionUsed || typeof node !== 'string') return node;
|
||||
const idx = node.indexOf(agentMention.token);
|
||||
if (idx === -1) return node;
|
||||
agentMentionUsed = true;
|
||||
return [
|
||||
node.slice(0, idx),
|
||||
<a
|
||||
key={`agent-${index}`}
|
||||
href={buildMentionUrl(agentMention.name)}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
@@ -130,11 +195,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{agentMention.token}
|
||||
</a>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}, [agentMention, textContent]);
|
||||
</a>,
|
||||
node.slice(idx + agentMention.token.length),
|
||||
];
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user