From f60993bd06aedc6978829d279acb8e54f562f49c Mon Sep 17 00:00:00 2001 From: Brandon Rose Date: Mon, 24 Aug 2026 09:49:42 -0500 Subject: [PATCH 1/7] Render skill markdown and make resource lists clickable Skill resources previously displayed as raw markdown in a read-only code editor, and the resource/example lists in the read-only skill viewer were inert. - SkillResourcePanel: markdown resources open in a rendered view; editable resources get an Edit/Preview toggle, read-only ones are rendered only. Non-markdown resources keep the code editor. - SkillIntegrationViewer: show the SKILL.md instructions (rendered), and make Available Resources / Code Examples items clickable, opening the resource focused in the right-side panel. - IntegrationsInterface: wire the open-resource event to the right panel via its exposed focusResource(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KxxsV6UyDEGTBGsMP4jeLy --- .../integrations/SkillIntegrationViewer.vue | 37 +++++++++++++- .../integrations/SkillResourcePanel.vue | 49 +++++++++++++++++++ .../src/pages/IntegrationsInterface.vue | 10 ++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue index 493787e5..e146c024 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue @@ -38,18 +38,24 @@ +
+
+
+

These resources are available to the agent and will be loaded on demand when the skill is active.

{{ resource.relative_path }} +
@@ -60,15 +66,17 @@

{{ example.filename }} {{ example.title }}
+
@@ -85,6 +93,7 @@ import { type IntegrationInterfaceState, type IntegrationResource, type SkillMetadataResource, + type SkillInstructionsResource, type SkillFileResource, type SkillExampleResource, filterByResourceType, @@ -99,6 +108,10 @@ const props = defineProps<{ fetchResources: () => Promise, }>(); +const emit = defineEmits<{ + (e: 'open-resource', resourceId: string): void, +}>(); + const model = defineModel(); const selectedIntegration = computed(() => @@ -107,6 +120,12 @@ const selectedIntegration = computed(() => const renderedDescription = computed(() => marked.parse(selectedIntegration.value?.description ?? "") as string); +const renderedInstructions = computed(() => { + const instructions = Object.values(filterByResourceType( + selectedIntegration.value?.resources, "skill_instructions"))[0]; + return instructions?.content ? marked.parse(instructions.content) as string : ""; +}); + const metadata = computed(() => { const resources = filterByResourceType( selectedIntegration.value?.resources, "skill_metadata"); @@ -202,6 +221,20 @@ const exampleResources = computed(() => { &:hover { background-color: var(--p-surface-100); } + + &.clickable { + cursor: pointer; + + .skill-resource-open-arrow { + margin-left: auto; + opacity: 0; + transition: opacity 150ms linear; + } + + &:hover .skill-resource-open-arrow { + opacity: 1; + } + } } .skill-resource-path { diff --git a/beaker-vue/src/components/integrations/SkillResourcePanel.vue b/beaker-vue/src/components/integrations/SkillResourcePanel.vue index 9bfc137b..85145993 100644 --- a/beaker-vue/src/components/integrations/SkillResourcePanel.vue +++ b/beaker-vue/src/components/integrations/SkillResourcePanel.vue @@ -81,6 +81,16 @@
@@ -99,6 +109,11 @@ Loading resource...
+
(() => { return focusedResource.value ? languageForPath(resourceLabel(focusedResource.value)) : 'markdown'; }); +const renderedContent = computed(() => + draftContent.value ? marked.parse(draftContent.value) as string : ""); + +// Markdown resources open in a rendered view by default; editable ones can be +// toggled into the raw editor. Non-markdown resources always use the editor. +const showRendered = ref(true); + +const showRenderedView = computed(() => + viewState.value.view === 'focused' + && focusedLanguage.value === 'markdown' + && (!editable.value || showRendered.value)); + const canSave = computed(() => { if (viewState.value.view === 'new') { return draftFilename.value.trim() !== ""; @@ -251,6 +279,7 @@ const startNew = (resourceType: "skill_file" | "skill_example") => { const openResource = async (resource: IntegrationResource) => { viewState.value = { view: 'focused', resourceId: resource.resource_id }; + showRendered.value = true; const cached = (resource as SkillFileResource | SkillExampleResource).content; if (cached !== undefined && cached !== null) { draftContent.value = cached; @@ -302,6 +331,17 @@ const removeResource = async (resource: IntegrationResource) => { delete selectedIntegration.value.resources[resource.resource_id]; }; +// Focus a resource from outside the panel (e.g. clicking a resource in the +// center skill viewer). +const focusResource = (resourceId: string) => { + const resource = selectedIntegration.value?.resources?.[resourceId]; + if (resource) { + openResource(resource); + } +}; + +defineExpose({ focusResource }); + From 6e5fe16315916d12d35a4563b7b38ad77ad8093a Mon Sep 17 00:00:00 2001 From: Brandon Rose Date: Mon, 24 Aug 2026 10:36:22 -0500 Subject: [PATCH 6/7] Sanitize rendered markdown, drop dead components, test link resolution - Rendered integration markdown (skill instructions, resource files, MCP descriptions) went into v-html unsanitized; skills can arrive from remote URLs and uploads, so route all integration marked.parse calls through renderMarkdown(), which sanitizes with DOMPurify. Lives in util/markdown.ts alongside the existing marked/KaTeX setup. - Delete ResourceViewer and ExamplesPanel: unreferenced since the skill editor rework (#252), and ResourceViewer still carried debug logging. - Unit-test isRelativeHref/resolveResourceFromHref (.., ./, fragments, query strings, externals) and renderMarkdown's sanitization. - Show empty custom-metadata values as blank instead of "null". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KxxsV6UyDEGTBGsMP4jeLy --- beaker-vue/package.json | 1 + .../src/__tests__/skillMarkdown.spec.ts | 90 ++++ .../components/integrations/ExamplesPanel.vue | 429 ------------------ .../integrations/IntegrationPanel.vue | 4 +- .../integrations/MCPIntegrationEditor.vue | 4 +- .../integrations/MCPIntegrationViewer.vue | 6 +- .../components/integrations/MCPToolsPanel.vue | 4 +- .../integrations/ResourceViewer.vue | 309 ------------- .../integrations/SkillIntegrationEditor.vue | 6 +- .../integrations/SkillIntegrationViewer.vue | 6 +- .../integrations/SkillResourcePanel.vue | 4 +- .../src/components/integrations/index.ts | 2 - beaker-vue/src/util/markdown.ts | 14 + 13 files changed, 122 insertions(+), 757 deletions(-) create mode 100644 beaker-vue/src/__tests__/skillMarkdown.spec.ts delete mode 100644 beaker-vue/src/components/integrations/ExamplesPanel.vue delete mode 100644 beaker-vue/src/components/integrations/ResourceViewer.vue diff --git a/beaker-vue/package.json b/beaker-vue/package.json index 538c5c72..94312d3b 100644 --- a/beaker-vue/package.json +++ b/beaker-vue/package.json @@ -66,6 +66,7 @@ "content-disposition": "^0.5.4", "cookie": "^1.0.2", "cytoscape": "^3.31.2", + "dompurify": "^3.4.14", "escape-html": "^1.0.3", "fflate": "^0.8.3", "filesize": "^10.1.6", diff --git a/beaker-vue/src/__tests__/skillMarkdown.spec.ts b/beaker-vue/src/__tests__/skillMarkdown.spec.ts new file mode 100644 index 00000000..9334f64b --- /dev/null +++ b/beaker-vue/src/__tests__/skillMarkdown.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + isRelativeHref, + resolveResourceFromHref, + type Integration, +} from '../util/integration'; +import { renderMarkdown } from '../util/markdown'; + +const integration = { + resources: { + 'r1': { resource_id: 'r1', resource_type: 'skill_file', relative_path: 'references/FILTERS.md' }, + 'r2': { resource_id: 'r2', resource_type: 'skill_file', relative_path: 'references/CROSS-REPOSITORY.md' }, + 'r3': { resource_id: 'r3', resource_type: 'skill_file', relative_path: 'assets/service_openapi.yaml' }, + 'e1': { resource_id: 'e1', resource_type: 'skill_example', filename: 'find_cohort.md' }, + 'i1': { resource_id: 'i1', resource_type: 'skill_instructions', content: '# hi' }, + }, +} as unknown as Integration; + +describe('isRelativeHref', () => { + it('accepts relative paths', () => { + expect(isRelativeHref('references/FILTERS.md')).toBe(true); + expect(isRelativeHref('./FILTERS.md')).toBe(true); + expect(isRelativeHref('../assets/service_openapi.yaml')).toBe(true); + }); + + it('rejects external, protocol-relative, mailto, and in-page links', () => { + expect(isRelativeHref('https://example.com/x.md')).toBe(false); + expect(isRelativeHref('http://example.com')).toBe(false); + expect(isRelativeHref('//example.com/x.md')).toBe(false); + expect(isRelativeHref('mailto:someone@example.com')).toBe(false); + expect(isRelativeHref('#section')).toBe(false); + expect(isRelativeHref('')).toBe(false); + }); +}); + +describe('resolveResourceFromHref', () => { + it('resolves a skill-root link to a file resource', () => { + expect(resolveResourceFromHref(integration, 'references/FILTERS.md')?.resource_id).toBe('r1'); + }); + + it('resolves an examples/ link to an example resource', () => { + expect(resolveResourceFromHref(integration, 'examples/find_cohort.md')?.resource_id).toBe('e1'); + }); + + it('resolves sibling links against the linking file directory', () => { + expect(resolveResourceFromHref(integration, 'CROSS-REPOSITORY.md', 'references')?.resource_id).toBe('r2'); + }); + + it('resolves ../ traversal', () => { + expect(resolveResourceFromHref(integration, '../assets/service_openapi.yaml', 'references')?.resource_id).toBe('r3'); + }); + + it('ignores ./ segments, query strings, and fragments', () => { + expect(resolveResourceFromHref(integration, './references/FILTERS.md')?.resource_id).toBe('r1'); + expect(resolveResourceFromHref(integration, 'references/FILTERS.md#operators')?.resource_id).toBe('r1'); + expect(resolveResourceFromHref(integration, 'references/FILTERS.md?x=1')?.resource_id).toBe('r1'); + }); + + it('returns undefined for unknown paths and empty hrefs', () => { + expect(resolveResourceFromHref(integration, 'auth.yaml')).toBeUndefined(); + expect(resolveResourceFromHref(integration, '')).toBeUndefined(); + expect(resolveResourceFromHref(undefined, 'references/FILTERS.md')).toBeUndefined(); + }); +}); + +describe('renderMarkdown', () => { + it('renders markdown to HTML', () => { + const html = renderMarkdown('# Title\n\nSome **bold** text.'); + expect(html).toContain('

'); + expect(html).toContain('bold'); + }); + + it('strips script tags and event handlers from embedded HTML', () => { + expect(renderMarkdown('hello ')).not.toContain(''); + expect(html).not.toContain('onerror'); + }); + + it('neutralizes javascript: links but keeps normal ones', () => { + expect(renderMarkdown('[x](javascript:alert(1))')).not.toContain('javascript:'); + expect(renderMarkdown('[x](https://example.com)')).toContain('href="https://example.com"'); + expect(renderMarkdown('[x](references/FILTERS.md)')).toContain('href="references/FILTERS.md"'); + }); + + it('returns an empty string for empty input', () => { + expect(renderMarkdown('')).toBe(''); + expect(renderMarkdown(undefined)).toBe(''); + expect(renderMarkdown(null)).toBe(''); + }); +}); diff --git a/beaker-vue/src/components/integrations/ExamplesPanel.vue b/beaker-vue/src/components/integrations/ExamplesPanel.vue deleted file mode 100644 index 0ea8b0d2..00000000 --- a/beaker-vue/src/components/integrations/ExamplesPanel.vue +++ /dev/null @@ -1,429 +0,0 @@ - - - - - - diff --git a/beaker-vue/src/components/integrations/IntegrationPanel.vue b/beaker-vue/src/components/integrations/IntegrationPanel.vue index 2084a13f..4cf119d6 100644 --- a/beaker-vue/src/components/integrations/IntegrationPanel.vue +++ b/beaker-vue/src/components/integrations/IntegrationPanel.vue @@ -165,7 +165,7 @@ import InputGroup from "primevue/inputgroup"; import InputGroupAddon from "primevue/inputgroupaddon"; import InputText from "primevue/inputtext"; import Card from "primevue/card"; -import { marked } from "marked"; +import { renderMarkdown } from "../../util/markdown"; import { type BeakerSessionComponentType } from "../session/BeakerSession.vue"; import { type IntegrationMap, type Integration, type IntegrationProviders, listIntegrations, getIntegrationProviderType, getIntegrationIcon, getIntegrationTypeLabel, isContextProvidedIntegration } from "@/util/integration"; import { useRoute, RouterLink } from "vue-router"; @@ -240,7 +240,7 @@ const filterIntegrations = (integrations: Integration[]) => const renderIntegrations = (integrations: Integration[]) => integrations.map(integration => - ({...integration, description: marked.parse(integration?.description ?? "") as string})) + ({...integration, description: renderMarkdown(integration?.description)})) const processIntegrations = (integrations: Integration[]) => renderIntegrations(filterIntegrations(sortIntegrations(integrations))) diff --git a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue index 6172cb2b..88cc3f04 100644 --- a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue +++ b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue @@ -323,7 +323,7 @@ import Checkbox from 'primevue/checkbox'; import Button from 'primevue/button'; import ProgressSpinner from 'primevue/progressspinner'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import CodeEditor from '../misc/CodeEditor.vue'; @@ -434,7 +434,7 @@ watch(() => selectedIntegration.value?.description, (current) => { // read-only here (see the Instructions fieldset). Not persisted to config. const renderedInstructions = computed(() => { const instructions = selectedIntegration.value?.instructions; - return instructions ? marked.parse(instructions) as string : ""; + return renderMarkdown(instructions); }); const hasServerInfo = computed(() => diff --git a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue index 941345ca..d64ef7c6 100644 --- a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue @@ -160,7 +160,7 @@ import Fieldset from 'primevue/fieldset'; import InputText from 'primevue/inputtext'; import ProgressSpinner from 'primevue/progressspinner'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; const props = defineProps<{ fetchResources: () => Promise, @@ -199,11 +199,11 @@ const serverConfig = computed(() => selectedIntegration.value?.server_config); const renderedDescription = computed(() => - marked.parse(selectedIntegration.value?.description ?? "") as string); + renderMarkdown(selectedIntegration.value?.description)); const renderedInstructions = computed(() => { const instructions = selectedIntegration.value?.instructions; - return instructions ? marked.parse(instructions) as string : ""; + return renderMarkdown(instructions); }); const hasServerInfo = computed(() => diff --git a/beaker-vue/src/components/integrations/MCPToolsPanel.vue b/beaker-vue/src/components/integrations/MCPToolsPanel.vue index 0bbd3d3f..c1504526 100644 --- a/beaker-vue/src/components/integrations/MCPToolsPanel.vue +++ b/beaker-vue/src/components/integrations/MCPToolsPanel.vue @@ -124,7 +124,7 @@ import Tag from 'primevue/tag'; import InputGroup from "primevue/inputgroup"; import InputGroupAddon from "primevue/inputgroupaddon"; import InputText from "primevue/inputtext"; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import { type IntegrationInterfaceState, type MCPToolResource, @@ -209,7 +209,7 @@ const focusedArgs = computed(() => const renderedDescription = computed(() => { if (viewState.value.view !== 'focused') return ""; const description = viewState.value.tool.description; - return description ? marked.parse(description) as string : ""; + return renderMarkdown(description); }); const viewTool = (tool: MCPToolResource) => { diff --git a/beaker-vue/src/components/integrations/ResourceViewer.vue b/beaker-vue/src/components/integrations/ResourceViewer.vue deleted file mode 100644 index 62a09e97..00000000 --- a/beaker-vue/src/components/integrations/ResourceViewer.vue +++ /dev/null @@ -1,309 +0,0 @@ - - - - - - diff --git a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue index 93d48f32..a6343b10 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue @@ -224,7 +224,7 @@ import InputChips from 'primevue/inputchips'; import Select from 'primevue/select'; import Button from 'primevue/button'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import CodeEditor from '../misc/CodeEditor.vue'; import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; @@ -314,7 +314,7 @@ const syncFromIntegration = () => { allowedToolsList.value = (metadata?.allowed_tools ?? '') .split(',').map((tool) => tool.trim()).filter((tool) => tool !== ''); metadataRows.value = Object.entries(metadata?.skill_metadata ?? {}) - .map(([key, value]) => ({ key, value: String(value) })); + .map(([key, value]) => ({ key, value: value == null ? '' : String(value) })); }; const markDirty = () => { @@ -360,7 +360,7 @@ const fetchFromUrl = async () => { }; const renderedInstructions = computed(() => - instructions.value ? marked.parse(instructions.value) as string : ""); + renderMarkdown(instructions.value)); // Instructions default to a rendered preview; Edit toggles the raw editor. // New/empty skills start in the editor since there is nothing to preview. diff --git a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue index 61f268bc..47e4e95c 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue @@ -105,7 +105,7 @@ import Fieldset from 'primevue/fieldset'; import InputText from 'primevue/inputtext'; import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; const props = defineProps<{ fetchResources: () => Promise, @@ -121,12 +121,12 @@ const selectedIntegration = computed(() => model.value.integrations[model.value.selected]); const renderedDescription = computed(() => - marked.parse(selectedIntegration.value?.description ?? "") as string); + renderMarkdown(selectedIntegration.value?.description)); const renderedInstructions = computed(() => { const instructions = Object.values(filterByResourceType( selectedIntegration.value?.resources, "skill_instructions"))[0]; - return instructions?.content ? marked.parse(instructions.content) as string : ""; + return renderMarkdown(instructions?.content); }); // SKILL.md links to sibling resource files (references/, examples/, ...) diff --git a/beaker-vue/src/components/integrations/SkillResourcePanel.vue b/beaker-vue/src/components/integrations/SkillResourcePanel.vue index e0ff8f3d..cc91b7a5 100644 --- a/beaker-vue/src/components/integrations/SkillResourcePanel.vue +++ b/beaker-vue/src/components/integrations/SkillResourcePanel.vue @@ -142,7 +142,7 @@ import Select from "primevue/select"; import InputText from "primevue/inputtext"; import ProgressSpinner from 'primevue/progressspinner'; import CodeEditor from '../misc/CodeEditor.vue'; -import { marked } from 'marked'; +import { renderMarkdown } from '../../util/markdown'; import { type IntegrationInterfaceState, type IntegrationResource, @@ -248,7 +248,7 @@ const focusedLanguage = computed(() => { }); const renderedContent = computed(() => - draftContent.value ? marked.parse(draftContent.value) as string : ""); + renderMarkdown(draftContent.value)); // Markdown resources open in a rendered view by default; editable ones can be // toggled into the raw editor. Non-markdown resources always use the editor. diff --git a/beaker-vue/src/components/integrations/index.ts b/beaker-vue/src/components/integrations/index.ts index 74ec3d9a..b83a7314 100644 --- a/beaker-vue/src/components/integrations/index.ts +++ b/beaker-vue/src/components/integrations/index.ts @@ -1,11 +1,9 @@ export { default as AdhocIntegrationEditor } from './AdhocIntegrationEditor.vue'; export { default as DeprecatedIntegrationEditor } from './DeprecatedIntegrationEditor.vue'; -export { default as ExamplesPanel } from './ExamplesPanel.vue'; export { default as IntegrationPanel } from './IntegrationPanel.vue'; export { default as MCPIntegrationEditor } from './MCPIntegrationEditor.vue'; export { default as MCPIntegrationViewer } from './MCPIntegrationViewer.vue'; export { default as MCPToolsPanel } from './MCPToolsPanel.vue'; -export { default as ResourceViewer } from './ResourceViewer.vue'; export { default as SkillIntegrationViewer } from './SkillIntegrationViewer.vue'; export { default as SkillIntegrationEditor } from './SkillIntegrationEditor.vue'; export { default as SkillResourcePanel } from './SkillResourcePanel.vue'; diff --git a/beaker-vue/src/util/markdown.ts b/beaker-vue/src/util/markdown.ts index 6e7d2625..edd0849f 100644 --- a/beaker-vue/src/util/markdown.ts +++ b/beaker-vue/src/util/markdown.ts @@ -1,5 +1,6 @@ import { marked, type TokenizerAndRendererExtension, type Tokens } from 'marked'; import katex from 'katex'; +import DOMPurify from 'dompurify'; import 'katex/dist/katex.min.css'; // KaTeX support for `marked`. @@ -131,4 +132,17 @@ export const registerMarkdownExtensions = (): void => { registerMarkdownExtensions(); +/** + * Render untrusted markdown to sanitized HTML safe for `v-html`. Integration + * content (skill instructions, resource files, MCP descriptions) can arrive + * from remote URLs or uploaded archives, so it must never reach the DOM as + * raw HTML. + */ +export const renderMarkdown = (markdown: string | null | undefined): string => { + if (!markdown) { + return ""; + } + return DOMPurify.sanitize(marked.parse(markdown) as string); +}; + export { marked }; From 0b2d02b5a79f15ebc4cf4299d519e44e506efdf6 Mon Sep 17 00:00:00 2001 From: Brandon Rose Date: Mon, 24 Aug 2026 10:55:46 -0500 Subject: [PATCH 7/7] Fix data-loss races and duplication found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (from adversarial review of the branch): - openResource: guard against stale fetch responses (a slow load for resource A no longer lands in — or gets saved over — resource B), track load failures visibly, and disable Save while content is loading or failed so a blank draft can't overwrite the file on disk. - Confirm before discarding an unsaved draft when navigation (row click, rendered link, center viewer, Back) opens another resource. - Only treat .md/.markdown files as markdown (a .csv/.txt no longer renders garbled through marked), and offer the Raw/Preview toggle on read-only markdown too, so the exact content the agent receives is always viewable. - Resolve relative links with a skill-root fallback (examples/ files use root-relative paths) and percent-decode hrefs before matching. - Don't yank the user out of the raw instructions editor when a resource refresh re-syncs state; flip to preview only when instructions first arrive into an empty editor. - ClampedMarkdown re-measures overflow on element resize and captured image loads, so late reflow can't clip content below an unreachable fold. - IntegrationPanel: render+sanitize descriptions in a cached computed instead of inline in the v-for (was re-sanitizing every card on every hover change and keystroke). Structure: - Deduplicate the relative-link click handling into resourceFromLinkClick() (was copy-pasted three times). - Extract the Available Resources / Code Examples fieldsets into SkillResourceLinks (was duplicated between viewer and editor). - Move the shared .skill-description prose styles into index.scss; components no longer depend on another component's style block. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KxxsV6UyDEGTBGsMP4jeLy --- .../src/__tests__/skillMarkdown.spec.ts | 10 ++ .../integrations/IntegrationPanel.vue | 13 +- .../integrations/SkillIntegrationEditor.vue | 72 ++-------- .../integrations/SkillIntegrationViewer.vue | 133 ++---------------- .../integrations/SkillResourceLinks.vue | 108 ++++++++++++++ .../integrations/SkillResourcePanel.vue | 94 ++++++++++--- .../src/components/misc/ClampedMarkdown.vue | 30 +++- beaker-vue/src/index.scss | 30 ++++ beaker-vue/src/util/integration.ts | 56 ++++++-- 9 files changed, 327 insertions(+), 219 deletions(-) create mode 100644 beaker-vue/src/components/integrations/SkillResourceLinks.vue diff --git a/beaker-vue/src/__tests__/skillMarkdown.spec.ts b/beaker-vue/src/__tests__/skillMarkdown.spec.ts index 9334f64b..78dcbb53 100644 --- a/beaker-vue/src/__tests__/skillMarkdown.spec.ts +++ b/beaker-vue/src/__tests__/skillMarkdown.spec.ts @@ -11,6 +11,7 @@ const integration = { 'r1': { resource_id: 'r1', resource_type: 'skill_file', relative_path: 'references/FILTERS.md' }, 'r2': { resource_id: 'r2', resource_type: 'skill_file', relative_path: 'references/CROSS-REPOSITORY.md' }, 'r3': { resource_id: 'r3', resource_type: 'skill_file', relative_path: 'assets/service_openapi.yaml' }, + 'r4': { resource_id: 'r4', resource_type: 'skill_file', relative_path: 'references/my notes.md' }, 'e1': { resource_id: 'e1', resource_type: 'skill_example', filename: 'find_cohort.md' }, 'i1': { resource_id: 'i1', resource_type: 'skill_instructions', content: '# hi' }, }, @@ -50,6 +51,15 @@ describe('resolveResourceFromHref', () => { expect(resolveResourceFromHref(integration, '../assets/service_openapi.yaml', 'references')?.resource_id).toBe('r3'); }); + it('falls back to the skill root when a base-relative link does not resolve', () => { + // Example files commonly use root-relative paths like SKILL.md does. + expect(resolveResourceFromHref(integration, 'references/FILTERS.md', 'examples')?.resource_id).toBe('r1'); + }); + + it('percent-decodes encoded hrefs', () => { + expect(resolveResourceFromHref(integration, 'references/my%20notes.md')?.resource_id).toBe('r4'); + }); + it('ignores ./ segments, query strings, and fragments', () => { expect(resolveResourceFromHref(integration, './references/FILTERS.md')?.resource_id).toBe('r1'); expect(resolveResourceFromHref(integration, 'references/FILTERS.md#operators')?.resource_id).toBe('r1'); diff --git a/beaker-vue/src/components/integrations/IntegrationPanel.vue b/beaker-vue/src/components/integrations/IntegrationPanel.vue index 4cf119d6..656d0396 100644 --- a/beaker-vue/src/components/integrations/IntegrationPanel.vue +++ b/beaker-vue/src/components/integrations/IntegrationPanel.vue @@ -80,7 +80,7 @@ >
integrations.map(integration => ({...integration, description: renderMarkdown(integration?.description)})) -const processIntegrations = (integrations: Integration[]) => - renderIntegrations(filterIntegrations(sortIntegrations(integrations))) +// Markdown parsing + sanitization is the expensive step, so cache it keyed on +// the integration data; the cheap search filter recomputes per keystroke on +// top of the cached result instead of re-rendering every description (which +// an inline template call would also do on every hover-state change). +const renderedIntegrations = computed(() => + renderIntegrations(sortIntegrations(Object.values(integrations.value ?? {})))); + +const displayIntegrations = computed(() => + filterIntegrations(renderedIntegrations.value)); // const relevantProviders = (providers: IntegrationProviders): IntegrationProviders => // Object.keys(providers) diff --git a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue index a6343b10..75b013c2 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue @@ -133,44 +133,11 @@ -
-

- These resources are available to the agent and will be loaded on demand when the skill is active. -

-
-
- - {{ resource.relative_path }} - -
-
-
- -
-

- Code examples demonstrating usage patterns for this skill. -

-
-
- -
- {{ example.filename }} - {{ example.title }} -
- -
-
-
+

@@ -212,8 +179,7 @@ import { type SkillFileResource, type SkillExampleResource, isContextProvidedIntegration, - isRelativeHref, - resolveResourceFromHref, + resourceFromLinkClick, filterByResourceType, previewRemoteSkill, } from '../../util/integration'; @@ -228,6 +194,7 @@ import { renderMarkdown } from '../../util/markdown'; import CodeEditor from '../misc/CodeEditor.vue'; import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; +import SkillResourceLinks from './SkillResourceLinks.vue'; const showToast = inject('show_toast'); @@ -366,15 +333,8 @@ const renderedInstructions = computed(() => // New/empty skills start in the editor since there is nothing to preview. const showInstructionsRendered = ref(true); -// SKILL.md links to sibling resource files (references/, examples/, ...) -// would 404 against the app's URL; open them in the resource panel instead. const onInstructionsLinkClick = (event: MouseEvent) => { - const anchor = (event.target as HTMLElement).closest?.('a'); - if (!anchor) return; - const href = anchor.getAttribute('href') ?? ''; - if (!isRelativeHref(href)) return; - event.preventDefault(); - const resource = resolveResourceFromHref(selectedIntegration.value, href); + const resource = resourceFromLinkClick(event, selectedIntegration.value); if (resource) { emit('open-resource', resource.resource_id); } @@ -403,8 +363,14 @@ watch(() => model.value.selected, () => { // save re-fetches the authoritative copy), unless the user has edits pending. watch(() => selectedIntegration.value?.resources, () => { if (!model.value.unsavedChanges) { + // Flip to the rendered preview only when instructions first arrive + // into an empty editor (the initial fetch resolving after selection); + // never yank the user out of an editor they are already using. + const wasEmpty = instructions.value === ''; syncFromIntegration(); - showInstructionsRendered.value = instructions.value !== ''; + if (wasEmpty && instructions.value !== '') { + showInstructionsRendered.value = true; + } } }); @@ -599,14 +565,6 @@ const remove = async () => { button { flex-shrink: 0; } } -.skill-description { - h1 { font-size: 1.25rem; margin-bottom: 1rem; } - h2 { font-size: 1.2rem; margin-bottom: 0.8rem; } - h3 { font-size: 1.15rem; margin-bottom: 0.8rem; } - p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; } - > *:first-child { margin-top: 0rem; } -} - .skill-readonly-note { display: flex; align-items: center; diff --git a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue index 47e4e95c..c0c69fd3 100644 --- a/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue +++ b/beaker-vue/src/components/integrations/SkillIntegrationViewer.vue @@ -42,44 +42,11 @@ -

-

- These resources are available to the agent and will be loaded on demand when the skill is active. -

-
-
- - {{ resource.relative_path }} - -
-
-
- -
-

- Code examples demonstrating usage patterns for this skill. -

-
-
- -
- {{ example.filename }} - {{ example.title }} -
- -
-
-
+
@@ -91,19 +58,18 @@ import { computed } from 'vue'; import { type Integration, type IntegrationInterfaceState, - type IntegrationResource, type SkillMetadataResource, type SkillInstructionsResource, type SkillFileResource, type SkillExampleResource, filterByResourceType, - isRelativeHref, - resolveResourceFromHref, + resourceFromLinkClick, } from '../../util/integration'; import Fieldset from 'primevue/fieldset'; import InputText from 'primevue/inputtext'; import ClampedMarkdown from '../misc/ClampedMarkdown.vue'; +import SkillResourceLinks from './SkillResourceLinks.vue'; import { renderMarkdown } from '../../util/markdown'; @@ -129,15 +95,8 @@ const renderedInstructions = computed(() => { return renderMarkdown(instructions?.content); }); -// SKILL.md links to sibling resource files (references/, examples/, ...) -// would 404 against the app's URL; open them in the resource panel instead. const onInstructionsLinkClick = (event: MouseEvent) => { - const anchor = (event.target as HTMLElement).closest?.('a'); - if (!anchor) return; - const href = anchor.getAttribute('href') ?? ''; - if (!isRelativeHref(href)) return; - event.preventDefault(); - const resource = resolveResourceFromHref(selectedIntegration.value, href); + const resource = resourceFromLinkClick(event, selectedIntegration.value); if (resource) { emit('open-resource', resource.resource_id); } @@ -186,34 +145,6 @@ const exampleResources = computed(() => { } } -.skill-description { - h1 { font-size: 1.25rem; margin-bottom: 1rem; } - h2 { font-size: 1.2rem; margin-bottom: 0.8rem; } - h3 { font-size: 1.15rem; margin-bottom: 0.8rem; } - p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; } - > *:first-child { margin-top: 0rem; } - - // Wide content (code blocks, tables) must scroll inside its own box - // rather than stretching the pane sideways. min-width: 0 lets this block - // shrink below its content's intrinsic width inside flex column parents. - min-width: 0; - max-width: 100%; - overflow-wrap: break-word; - - pre { - max-width: 100%; - overflow-x: auto; - } - table { - display: block; - max-width: 100%; - overflow-x: auto; - } - img { - max-width: 100%; - } -} - .skill-metadata-grid { display: flex; flex-direction: column; @@ -240,52 +171,4 @@ const exampleResources = computed(() => { .skill-no-metadata { color: var(--p-text-muted-color); } - -.skill-resource-list { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.skill-resource-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.35rem 0.5rem; - border-radius: 4px; - font-size: 0.9rem; - - &:hover { - background-color: var(--p-surface-100); - } - - &.clickable { - cursor: pointer; - - .skill-resource-open-arrow { - margin-left: auto; - opacity: 0; - transition: opacity 150ms linear; - } - - &:hover .skill-resource-open-arrow { - opacity: 1; - } - } -} - -.skill-resource-path { - font-family: monospace; -} - -.skill-example-info { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.skill-example-title { - font-size: 0.85rem; - color: var(--p-text-muted-color); -} diff --git a/beaker-vue/src/components/integrations/SkillResourceLinks.vue b/beaker-vue/src/components/integrations/SkillResourceLinks.vue new file mode 100644 index 00000000..69b59ff4 --- /dev/null +++ b/beaker-vue/src/components/integrations/SkillResourceLinks.vue @@ -0,0 +1,108 @@ + + + + + + + diff --git a/beaker-vue/src/components/integrations/SkillResourcePanel.vue b/beaker-vue/src/components/integrations/SkillResourcePanel.vue index cc91b7a5..c34c254d 100644 --- a/beaker-vue/src/components/integrations/SkillResourcePanel.vue +++ b/beaker-vue/src/components/integrations/SkillResourcePanel.vue @@ -79,12 +79,12 @@