fix installed skills discovery and improve editor UX (#1296)
* fix skills discovery from opencode * Fix stale skill description after frontmatter removal * fix: align vscode skill discovery parity
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { PreviewToggleButton } from '@/components/views/PreviewToggleButton';
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
@@ -29,6 +33,12 @@ import {
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
export interface SkillsPageProps {
|
||||
view?: 'installed' | 'catalog';
|
||||
@@ -38,8 +48,57 @@ const SkillsCatalogStandalone: React.FC = () => (
|
||||
<SkillsCatalogPage mode="external" onModeChange={() => {}} showModeTabs={false} />
|
||||
);
|
||||
|
||||
type SkillDocumentParseResult = {
|
||||
description: string | null;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
const SKILL_DOCUMENT_PATH = 'SKILL.md';
|
||||
const SKILL_EDITOR_HEIGHT_CLASS = 'h-[clamp(320px,58dvh,680px)] min-h-[260px] max-h-[calc(100dvh-220px)]';
|
||||
|
||||
const buildSkillMarkdown = (description: string, instructions: string): string => {
|
||||
const frontmatter = stringifyYaml({ description }).trimEnd();
|
||||
const body = instructions.trimStart();
|
||||
return `---\n${frontmatter}\n---${body ? `\n\n${body}` : '\n'}`;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const parseSkillMarkdown = (value: string): SkillDocumentParseResult => {
|
||||
const match = value.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return { description: null, instructions: value };
|
||||
}
|
||||
|
||||
let description: string | null = null;
|
||||
try {
|
||||
const frontmatter: unknown = parseYaml(match[1]);
|
||||
if (isRecord(frontmatter)) {
|
||||
const candidate = frontmatter.description;
|
||||
if (typeof candidate === 'string') {
|
||||
description = candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
description = null;
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
instructions: match[2].replace(/^\r?\n/, ''),
|
||||
};
|
||||
};
|
||||
|
||||
const replaceSkillMarkdownDescription = (value: string, description: string): string => {
|
||||
const parsed = parseSkillMarkdown(value);
|
||||
return buildSkillMarkdown(description, parsed.instructions);
|
||||
};
|
||||
|
||||
const SkillsInstalledPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -65,6 +124,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
|
||||
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasStaleSelection) {
|
||||
@@ -79,6 +139,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [instructions, setInstructions] = React.useState('');
|
||||
const [skillMarkdown, setSkillMarkdown] = React.useState(() => buildSkillMarkdown('', ''));
|
||||
const [skillEditorMode, setSkillEditorMode] = React.useState<'edit' | 'preview'>('edit');
|
||||
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
@@ -141,11 +203,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
const nextDescription = skillDraft.description || '';
|
||||
const nextInstructions = skillDraft.instructions || '';
|
||||
setDraftName(skillDraft.name || '');
|
||||
setDraftScope(skillDraft.scope || 'user');
|
||||
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
|
||||
setDescription(skillDraft.description || '');
|
||||
setInstructions(skillDraft.instructions || '');
|
||||
setDescription(nextDescription);
|
||||
setInstructions(nextInstructions);
|
||||
setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions));
|
||||
setOriginalDescription('');
|
||||
setOriginalInstructions('');
|
||||
setSupportingFiles([]);
|
||||
@@ -156,10 +221,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
if (detail) {
|
||||
const md = detail.sources.md;
|
||||
setDescription(md.description || '');
|
||||
setInstructions(md.instructions || '');
|
||||
setOriginalDescription(md.description || '');
|
||||
setOriginalInstructions(md.instructions || '');
|
||||
const nextDescription = md.description || '';
|
||||
const nextInstructions = md.instructions || '';
|
||||
setDescription(nextDescription);
|
||||
setInstructions(nextInstructions);
|
||||
setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions));
|
||||
setOriginalDescription(nextDescription);
|
||||
setOriginalInstructions(nextInstructions);
|
||||
setSupportingFiles(md.supportingFiles || []);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -173,6 +241,39 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
loadSkillDetails();
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
||||
|
||||
const skillEditorExtensions = React.useMemo<Extension[]>(() => {
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||
const markdownExtension = languageByExtension(SKILL_DOCUMENT_PATH);
|
||||
if (markdownExtension) {
|
||||
extensions.push(markdownExtension);
|
||||
}
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
return extensions;
|
||||
}, [currentTheme]);
|
||||
|
||||
const supportingFileEditorExtensions = React.useMemo<Extension[]>(() => {
|
||||
const filePath = newFileName.trim() || 'supporting-file.md';
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||
const languageExtension = languageByExtension(filePath);
|
||||
if (languageExtension) {
|
||||
extensions.push(languageExtension);
|
||||
}
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
return extensions;
|
||||
}, [currentTheme, newFileName]);
|
||||
|
||||
const handleDescriptionChange = React.useCallback((nextDescription: string) => {
|
||||
setDescription(nextDescription);
|
||||
setSkillMarkdown((current) => replaceSkillMarkdownDescription(current, nextDescription));
|
||||
}, []);
|
||||
|
||||
const handleSkillMarkdownChange = React.useCallback((nextMarkdown: string) => {
|
||||
setSkillMarkdown(nextMarkdown);
|
||||
const parsed = parseSkillMarkdown(nextMarkdown);
|
||||
setDescription(parsed.description ?? '');
|
||||
setInstructions(parsed.instructions);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
|
||||
@@ -469,10 +570,11 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="mt-1.5">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||
disabled={isReadOnlySkill}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,19 +584,44 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.skills.page.section.instructions')}
|
||||
</h3>
|
||||
<PreviewToggleButton
|
||||
currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'}
|
||||
onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder={t('settings.skills.page.field.instructionsPlaceholder')}
|
||||
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background',
|
||||
SKILL_EDITOR_HEIGHT_CLASS,
|
||||
)}
|
||||
>
|
||||
{skillEditorMode === 'preview' ? (
|
||||
<ScrollableOverlay outerClassName="h-full" className="h-full">
|
||||
<div className="min-h-full px-4 py-3">
|
||||
<SimpleMarkdownRenderer
|
||||
content={skillMarkdown}
|
||||
className="typography-markdown-body"
|
||||
stripFrontmatter
|
||||
/>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
) : (
|
||||
<CodeMirrorEditor
|
||||
value={skillMarkdown}
|
||||
onChange={handleSkillMarkdownChange}
|
||||
readOnly={isReadOnlySkill}
|
||||
extensions={skillEditorExtensions}
|
||||
className="h-full"
|
||||
enableSearch
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -504,7 +631,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.skills.page.section.supportingFiles')}
|
||||
</h3>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile} disabled={isReadOnlySkill}>
|
||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -536,16 +663,18 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
{t('settings.skills.page.badge.pending')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(file.path);
|
||||
}}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3 w-3" />
|
||||
</Button>
|
||||
{!isReadOnlySkill && (
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFile(file.path);
|
||||
}}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -558,7 +687,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<div className="px-2 py-1">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasSkillChanges}
|
||||
disabled={isReadOnlySkill || isSaving || !hasSkillChanges}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
@@ -638,13 +767,15 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
{t('settings.skills.page.fileDialog.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder={t('settings.skills.page.fileDialog.field.contentPlaceholder')}
|
||||
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
|
||||
className="h-full min-h-0 font-mono typography-meta"
|
||||
/>
|
||||
<div className="h-[45vh] min-h-[250px] max-h-[55vh] overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background">
|
||||
<CodeMirrorEditor
|
||||
value={newFileContent}
|
||||
onChange={setNewFileContent}
|
||||
extensions={supportingFileEditorExtensions}
|
||||
className="h-full"
|
||||
enableSearch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,10 @@ interface SkillsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
const BUILT_IN_SKILL_LOCATION = '<built-in>';
|
||||
|
||||
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
|
||||
|
||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||
@@ -78,6 +82,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
};
|
||||
|
||||
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
|
||||
if (isBuiltInSkill(skill)) return;
|
||||
setDeleteDialogSkill(skill);
|
||||
};
|
||||
|
||||
@@ -85,6 +90,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
if (!deleteDialogSkill) {
|
||||
return;
|
||||
}
|
||||
if (isBuiltInSkill(deleteDialogSkill)) {
|
||||
setDeleteDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteSkill(deleteDialogSkill.name);
|
||||
@@ -98,6 +107,8 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
};
|
||||
|
||||
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
|
||||
if (isBuiltInSkill(skill)) return;
|
||||
|
||||
const baseName = skill.name;
|
||||
let copyNumber = 1;
|
||||
let newName = `${baseName}-copy`;
|
||||
@@ -127,12 +138,17 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
};
|
||||
|
||||
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
|
||||
if (isBuiltInSkill(skill)) return;
|
||||
setRenameNewName(skill.name);
|
||||
setRenameDialogSkill(skill);
|
||||
};
|
||||
|
||||
const handleRenameSkill = async () => {
|
||||
if (!renameDialogSkill) return;
|
||||
if (isBuiltInSkill(renameDialogSkill)) {
|
||||
setRenameDialogSkill(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
@@ -443,6 +459,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
? t('settings.skills.sidebar.badge.agents')
|
||||
: t('settings.skills.sidebar.badge.opencode');
|
||||
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
|
||||
const isBuiltIn = isBuiltInSkill(skill);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -471,7 +488,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
{!isBuiltIn ? <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
@@ -512,7 +529,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
{t('settings.common.actions.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,12 +53,17 @@ import {
|
||||
const SETTINGS_NAV_MIN_WIDTH = 176;
|
||||
const SETTINGS_NAV_MAX_WIDTH = 280;
|
||||
const SETTINGS_NAV_RESIZE_STEP = 8;
|
||||
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail';
|
||||
|
||||
function clampSettingsNavWidth(width: number): number {
|
||||
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
|
||||
type SettingsDetailHistoryEntry = {
|
||||
page: SettingsPageSlug;
|
||||
stage: 'page-content';
|
||||
};
|
||||
|
||||
interface SettingsViewProps {
|
||||
onClose?: () => void;
|
||||
@@ -106,6 +111,37 @@ function isPageAvailable(page: SettingsPageMeta, ctx: SettingsRuntimeContext): b
|
||||
return page.isAvailable(ctx);
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getSettingsDetailHistoryEntry(state: unknown): SettingsDetailHistoryEntry | null {
|
||||
if (!isObjectRecord(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = state[SETTINGS_DETAIL_HISTORY_KEY];
|
||||
if (!isObjectRecord(detail)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = detail.page;
|
||||
const stage = detail.stage;
|
||||
if (typeof page !== 'string' || stage !== 'page-content') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPage = resolveSettingsSlug(page);
|
||||
return { page: resolvedPage, stage };
|
||||
}
|
||||
|
||||
function getCurrentHistoryState(): Record<string, unknown> {
|
||||
if (typeof window === 'undefined' || !isObjectRecord(window.history.state)) {
|
||||
return {};
|
||||
}
|
||||
return window.history.state;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
switch (slug) {
|
||||
@@ -559,11 +595,84 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}, [isMobile, mobileStage, settingsSlug]);
|
||||
|
||||
const showBackButton = isMobile && mobileStage !== 'nav';
|
||||
const backButtonTargetsPageSidebar = isMobile && mobileStage === 'page-content' && settingsSlug === 'skills.installed';
|
||||
const showOpenPageSidebarButton = mobileStage === 'page-content'
|
||||
&& activePageMeta?.kind === 'split'
|
||||
&& !backButtonTargetsPageSidebar;
|
||||
const mobileBackButtonLabel = backButtonTargetsPageSidebar
|
||||
? t('settings.view.actions.back')
|
||||
: showBackButton
|
||||
? t('settings.view.actions.backToSettings')
|
||||
: t('settings.view.actions.closeSettings');
|
||||
const shortcutKey = getModifierLabel();
|
||||
|
||||
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
|
||||
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDetail = getSettingsDetailHistoryEntry(window.history.state);
|
||||
if (currentDetail?.page === slug && currentDetail.stage === 'page-content') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState(
|
||||
{
|
||||
...getCurrentHistoryState(),
|
||||
[SETTINGS_DETAIL_HISTORY_KEY]: { page: slug, stage: 'page-content' },
|
||||
},
|
||||
'',
|
||||
window.location.href,
|
||||
);
|
||||
}, [runtimeCtx.isVSCode]);
|
||||
|
||||
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
|
||||
setMobileStage('page-content');
|
||||
if (settingsSlug === 'skills.installed') {
|
||||
pushMobileSplitDetailHistory(settingsSlug);
|
||||
}
|
||||
}, [pushMobileSplitDetailHistory, settingsSlug]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
if (backButtonTargetsPageSidebar) {
|
||||
const currentDetail = typeof window !== 'undefined'
|
||||
? getSettingsDetailHistoryEntry(window.history.state)
|
||||
: null;
|
||||
if (currentDetail?.page === settingsSlug && !runtimeCtx.isVSCode) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
setMobileStage('page-sidebar');
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileStage('nav');
|
||||
}, []);
|
||||
}, [backButtonTargetsPageSidebar, runtimeCtx.isVSCode, settingsSlug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || runtimeCtx.isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
if (settingsSlug !== 'skills.installed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = getSettingsDetailHistoryEntry(event.state);
|
||||
if (detail?.page === 'skills.installed') {
|
||||
setMobileStage('page-content');
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileStage((stage) => stage === 'page-content' ? 'page-sidebar' : stage);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [isMobile, runtimeCtx.isVSCode, settingsSlug]);
|
||||
|
||||
const handleOpenPageSidebar = React.useCallback(() => {
|
||||
setMobileStage('page-sidebar');
|
||||
@@ -670,7 +779,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return (
|
||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<ErrorBoundary>
|
||||
{renderPageSidebar(settingsSlug, { onItemSelect: () => setMobileStage('page-content') })}
|
||||
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
@@ -724,7 +833,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
<button
|
||||
type="button"
|
||||
onClick={showBackButton ? handleBack : onClose}
|
||||
aria-label={showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings')}
|
||||
aria-label={mobileBackButtonLabel}
|
||||
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="arrow-left-s" className="h-5 w-5" />
|
||||
@@ -736,7 +845,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
|
||||
</div>
|
||||
|
||||
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && (
|
||||
{showOpenPageSidebarButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPageSidebar}
|
||||
|
||||
@@ -1415,7 +1415,7 @@ class OpencodeService {
|
||||
for (const item of data as Array<Record<string, unknown>>) {
|
||||
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||||
const location = typeof item.location === 'string' ? item.location : '';
|
||||
if (!name || !location || location === '<built-in>') {
|
||||
if (!name || !location) {
|
||||
continue;
|
||||
}
|
||||
const skill: { name: string; description?: string; location: string; content?: string } = { name, location };
|
||||
|
||||
Reference in New Issue
Block a user