+
+
+ Failed to load resource content.
+
(() => {
const renderedContent = computed(() =>
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.
+// Markdown resources open in a rendered view by default, with a toggle back
+// to the raw source (an editor for editable skills, read-only otherwise).
+// Non-markdown resources always use the code editor.
const showRendered = ref(true);
-const showRenderedView = computed(() =>
+const isMarkdownPath = (path: string): boolean => /\.(md|markdown)$/i.test(path);
+
+const focusedIsMarkdown = computed(() =>
viewState.value.view === 'focused'
- && focusedLanguage.value === 'markdown'
- && (!editable.value || showRendered.value));
+ && focusedResource.value !== undefined
+ && isMarkdownPath(resourceLabel(focusedResource.value)));
+
+const showRenderedView = computed(() =>
+ focusedIsMarkdown.value && showRendered.value);
+
+// A failed or still-running load means draftContent does not reflect the
+// resource; saving then would overwrite the real content with an empty string.
+const loadFailed = ref(false);
const canSave = computed(() => {
if (viewState.value.view === 'new') {
return draftFilename.value.trim() !== "";
}
- return true;
+ return !loadingContent.value && !loadFailed.value;
+});
+
+// Unsaved work in the focused editor or the new-resource form; guard against
+// silently discarding it when a row click, rendered link, or the center
+// viewer opens another resource.
+const draftDirty = computed(() => {
+ if (viewState.value.view === 'new') {
+ return draftFilename.value.trim() !== '' || draftContent.value !== '';
+ }
+ if (viewState.value.view === 'focused' && editable.value && !loadingContent.value && !loadFailed.value) {
+ const original = (focusedResource.value as SkillFileResource | SkillExampleResource | undefined)?.content ?? '';
+ return draftContent.value !== original;
+ }
+ return false;
});
+const confirmDiscardDraft = (): boolean =>
+ !draftDirty.value || confirm('Discard unsaved changes?');
+
const backToList = () => {
viewState.value = { view: 'list' };
draftFilename.value = "";
@@ -273,6 +303,12 @@ const backToList = () => {
draftDir.value = "references";
};
+const onBackClick = () => {
+ if (confirmDiscardDraft()) {
+ backToList();
+ }
+};
+
const startNew = (resourceType: "skill_file" | "skill_example") => {
draftFilename.value = "";
draftContent.value = "";
@@ -281,8 +317,11 @@ const startNew = (resourceType: "skill_file" | "skill_example") => {
};
const openResource = async (resource: IntegrationResource) => {
+ if (!confirmDiscardDraft()) return;
viewState.value = { view: 'focused', resourceId: resource.resource_id };
showRendered.value = true;
+ loadFailed.value = false;
+ loadingContent.value = false;
const cached = (resource as SkillFileResource | SkillExampleResource).content;
if (cached !== undefined && cached !== null) {
draftContent.value = cached;
@@ -290,15 +329,27 @@ const openResource = async (resource: IntegrationResource) => {
}
draftContent.value = "";
loadingContent.value = true;
+ // Only the fetch for the currently focused resource may touch shared
+ // state: the user can focus another resource while this one is loading,
+ // and a late response must not leak into it (or be saved over it).
+ const isCurrent = () =>
+ viewState.value.view === 'focused' && viewState.value.resourceId === resource.resource_id;
try {
const fetched = await getResource(props.sessionId, model.value.selected, resource.resource_type, resource.resource_id);
const content = (fetched as any).content ?? "";
(resource as any).content = content;
- draftContent.value = content;
+ if (isCurrent()) {
+ draftContent.value = content;
+ }
} catch (e) {
console.error('Failed to load resource content:', e);
+ if (isCurrent()) {
+ loadFailed.value = true;
+ }
} finally {
- loadingContent.value = false;
+ if (isCurrent()) {
+ loadingContent.value = false;
+ }
}
};
@@ -338,14 +389,9 @@ const removeResource = async (resource: IntegrationResource) => {
// inside references/FILTERS.md) would 404 against the app's URL; open the
// linked resource in this panel instead. External links behave normally.
const onRenderedLinkClick = (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 label = focusedLabel.value;
const basePath = label.includes('/') ? label.slice(0, label.lastIndexOf('/')) : '';
- const resource = resolveResourceFromHref(selectedIntegration.value, href, basePath);
+ const resource = resourceFromLinkClick(event, selectedIntegration.value, basePath);
if (resource) {
openResource(resource);
}
@@ -512,6 +558,14 @@ defineExpose({ focusResource });
}
}
+.skill-resource-error {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1rem 0.25rem;
+ color: var(--p-text-muted-color);
+}
+
.skill-resource-loading {
display: flex;
flex-direction: column;
diff --git a/beaker-vue/src/components/misc/ClampedMarkdown.vue b/beaker-vue/src/components/misc/ClampedMarkdown.vue
index 1193c968..0a531fdf 100644
--- a/beaker-vue/src/components/misc/ClampedMarkdown.vue
+++ b/beaker-vue/src/components/misc/ClampedMarkdown.vue
@@ -6,6 +6,7 @@
:class="{ clamped: !expanded, faded: !expanded && overflowing }"
v-html="html"
@click="(event) => emit('link-click', event)"
+ @load.capture="measure"
>
();
@@ -38,13 +39,36 @@ const contentEl = ref();
const expanded = ref(false);
const overflowing = ref(false);
+// Content height changes after the initial render — pane resizes re-wrap the
+// text, images and webfonts load late — so re-measure on element resize and
+// on captured load events, not just on content change. While expanded,
+// scrollHeight equals clientHeight, so measuring would wrongly clear the
+// flag (hiding "Show less"); skip until collapsed again.
+const measure = () => {
+ if (expanded.value) return;
+ const el = contentEl.value;
+ overflowing.value = !!el && el.scrollHeight > el.clientHeight + 1;
+};
+
watch(() => props.html, async () => {
expanded.value = false;
await nextTick();
- const el = contentEl.value;
- overflowing.value = !!el && el.scrollHeight > el.clientHeight + 1;
+ measure();
}, { immediate: true });
+let resizeObserver: ResizeObserver | undefined;
+
+onMounted(() => {
+ resizeObserver = new ResizeObserver(measure);
+ if (contentEl.value) {
+ resizeObserver.observe(contentEl.value);
+ }
+});
+
+onBeforeUnmount(() => {
+ resizeObserver?.disconnect();
+});
+
diff --git a/beaker-vue/src/index.scss b/beaker-vue/src/index.scss
index 0407dad2..e96c6991 100644
--- a/beaker-vue/src/index.scss
+++ b/beaker-vue/src/index.scss
@@ -10,3 +10,33 @@ body {
margin: 0;
padding: 0;
}
+
+// Shared typography for rendered (sanitized) markdown prose — skill
+// descriptions/instructions, resource files, MCP descriptions. Wide content
+// (code blocks, tables) scrolls inside its own box rather than stretching the
+// pane sideways; min-width: 0 lets the block shrink below its content's
+// intrinsic width inside flex column parents.
+.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; }
+
+ 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%;
+ }
+}
diff --git a/beaker-vue/src/util/integration.ts b/beaker-vue/src/util/integration.ts
index 8494cffd..245527fa 100644
--- a/beaker-vue/src/util/integration.ts
+++ b/beaker-vue/src/util/integration.ts
@@ -210,28 +210,62 @@ export const getIntegrationProviderSlug = (integration: Integration) => integrat
export const isRelativeHref = (href: string): boolean =>
!!href && !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href);
+const joinSkillPath = (basePath: string, href: string): string => {
+ const resolved: string[] = [];
+ for (const segment of [...basePath.split('/'), ...href.split('/')]) {
+ if (segment === '' || segment === '.') continue;
+ if (segment === '..') resolved.pop();
+ else resolved.push(segment);
+ }
+ return resolved.join('/');
+};
+
// Resolve a relative href authored inside a skill's markdown to another
// resource of the same integration. `basePath` is the directory of the file
// containing the link ("" for SKILL.md at the skill root), so sibling links
-// like `CROSS-REPOSITORY.md` inside references/FILTERS.md resolve correctly.
+// like `CROSS-REPOSITORY.md` inside references/FILTERS.md resolve correctly;
+// links that don't resolve against the base fall back to the skill root,
+// since files commonly use root-relative paths like `references/X.md`.
// Returns undefined when the link doesn't point at a known resource.
export const resolveResourceFromHref = (
integration: Integration | undefined,
href: string,
basePath: string = "",
): IntegrationResource | undefined => {
- const cleaned = href.split(/[?#]/)[0];
+ let cleaned = href.split(/[?#]/)[0];
if (!cleaned) return undefined;
- const resolved: string[] = [];
- for (const segment of [...basePath.split('/'), ...cleaned.split('/')]) {
- if (segment === '' || segment === '.') continue;
- if (segment === '..') resolved.pop();
- else resolved.push(segment);
+ try {
+ // Markdown links percent-encode spaces and non-ASCII characters.
+ cleaned = decodeURIComponent(cleaned);
+ } catch {
+ // Malformed escape sequence; match against the raw path.
}
- const path = resolved.join('/');
- return Object.values(integration?.resources ?? {}).find((r) =>
- (r.resource_type === 'skill_file' && (r as SkillFileResource).relative_path === path)
- || (r.resource_type === 'skill_example' && `examples/${(r as SkillExampleResource).filename}` === path));
+ const findByPath = (path: string): IntegrationResource | undefined =>
+ Object.values(integration?.resources ?? {}).find((r) =>
+ (r.resource_type === 'skill_file' && (r as SkillFileResource).relative_path === path)
+ || (r.resource_type === 'skill_example' && `examples/${(r as SkillExampleResource).filename}` === path));
+ const resource = findByPath(joinSkillPath(basePath, cleaned));
+ if (resource || !basePath) {
+ return resource;
+ }
+ return findByPath(joinSkillPath("", cleaned));
+};
+
+// Handle a click inside rendered skill markdown. A click on a relative link
+// (which would otherwise 404 against the app's URL) is prevented and resolved
+// to the resource it points at, if any; clicks elsewhere, and on external/
+// mailto/in-page links, are left alone and return undefined.
+export const resourceFromLinkClick = (
+ event: MouseEvent,
+ integration: Integration | undefined,
+ basePath: string = "",
+): IntegrationResource | undefined => {
+ const anchor = (event.target as HTMLElement).closest?.('a');
+ if (!anchor) return undefined;
+ const href = anchor.getAttribute('href') ?? '';
+ if (!isRelativeHref(href)) return undefined;
+ event.preventDefault();
+ return resolveResourceFromHref(integration, href, basePath);
};
// Per-datatype display metadata. Single source of truth for how each