Compare commits

...
3 Commits
Author SHA1 Message Date
esengine 44e3a19f8d fix(studio): a stuck, open activity heading is painted in the conversation's colour
design/turn.css set the open summary transparent while styles/studio.css makes it sticky, so rows scrolling under it showed through.
2026-11-08 18:46:47 -08:00
esengine 1738c499df fix(skill): the missing-description warning is said only for a skill that is kept
A nested file without a description is dropped from the scan, yet parseSkill warned that it would load. The warning moved to scanDir, where the keep decision is made, so docs such as pack/README.md stop raising it.
2026-11-08 18:40:09 -08:00
YHH 9ee11c6b1f fix(studio): clarify model choices and merge duplicate file entry (#15040) 2026-11-08 18:17:43 -08:00
19 changed files with 176 additions and 48 deletions

No files matched your search

+1
View File
@@ -143,6 +143,7 @@ jobs:
node perf/hoverradius.mjs
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/model-menu-badge.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
+8
View File
@@ -61,6 +61,14 @@ function benchPack(): ThemePack | null {
class BenchPort extends MockPort {
private driverRunning: boolean | undefined;
async providerHealthSettings() {
return query.has("modelhealth") ? { enabled: true } : super.providerHealthSettings();
}
async providerHealth(name: string) {
return query.has("modelhealth")
? { connection: "reachable" as const, wallet: "unsupported" as const, checkedAt: Date.now() }
: super.providerHealth(name);
}
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
async account() {
+78 -19
View File
@@ -1,28 +1,87 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const PAGE = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
PAGE.searchParams.set("modelhealth", "1");
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1300, height: 900 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".model-picker");
await page.locator(".model-picker").first().click();
await page.waitForSelector(".studio-model-menu .studio-item-line");
const sizes = await page.evaluate(() => {
const lines = [...document.querySelectorAll(".studio-model-menu .studio-item-line")].filter((line) => line.querySelector(".studio-item-badge"));
for (const line of lines) line.querySelector(".lb").textContent = "workbuddy/deepseek-v4.1-flash-preview-extended";
return lines.map((line) => {
const badge = line.querySelector(".studio-item-badge").getBoundingClientRect();
return { width: Math.round(badge.width), height: Math.round(badge.height) };
await mkdir("perf/shots", { recursive: true });
for (const lang of ["zh", "en"]) for (const width of [1300, 760, 390]) for (const scale of [1, 1.3]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: lang === "zh" ? "light" : "dark" });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript((language) => localStorage.setItem("rx-lang", language), lang);
await page.goto(PAGE.href, { waitUntil: "networkidle" });
await page.waitForSelector(".model-picker");
await page.evaluate((value) => document.documentElement.style.setProperty("--fs-scale", String(value)), scale);
await page.locator(".model-picker").first().click();
const menu = page.locator(".studio-model-menu:not([hidden])");
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
const effortWrapped = await menu.locator(".menu-aside .studio-item-line").evaluateAll((lines) => lines.some((line) => {
const meta = line.querySelector(".studio-item-meta");
return meta && meta.getBoundingClientRect().top >= line.querySelector(".lb").getBoundingClientRect().bottom;
}));
assert.ok(!effortWrapped, `${lang}/${width}/${scale}: reasoning choices keep their label and tier together`);
const vision = menu.locator(".menu-rows button.mi:has(.studio-item-badge)").first();
const visionRef = await vision.getAttribute("data-value");
await vision.click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, visionRef);
await page.keyboard.press("Escape");
await page.locator(".model-picker").first().click();
await menu.locator('.menu-item-status[data-tone="positive"]').first().waitFor();
await menu.evaluate((element) => {
for (const line of element.querySelectorAll(".menu-rows .studio-item-line")) {
line.querySelector(".lb").textContent = "deepseek-v4.1-flash-preview-extended";
}
for (const head of element.querySelectorAll(".menu-rows .mi.head")) {
head.querySelector(".lb").textContent = "Shanghai_AI_Lab";
head.querySelector(".rt").textContent = "discovery-api.intern-ai.org.cn/compatible-mode/v1";
}
});
});
assert.ok(sizes.length > 0, "the menu has a model that reads images");
for (const size of sizes) assert.ok(size.height <= 20, `a long model name leaves the badge on one line (${JSON.stringify(size)})`);
assert.deepEqual(errors, []);
console.log(`PASS model menu badge stays on one line beside a long name (${sizes.length} rows)`);
const measured = await menu.evaluate((element) => {
const rows = element.querySelector(".menu-rows");
const sizes = [...rows.querySelectorAll(".studio-item-line")].map((line) => {
const label = line.querySelector(".lb");
const badge = line.querySelector(".studio-item-badge");
const status = line.closest("button").querySelector(".menu-item-status");
const labelBox = label.getBoundingClientRect();
const badgeBox = badge?.getBoundingClientRect();
return {
labelWidth: labelBox.width,
badgeHeight: badgeBox?.height,
badgeLine: badge ? parseFloat(getComputedStyle(badge).lineHeight) + 4 : 0,
badgeClipped: badge ? badge.scrollWidth > badge.clientWidth + 1 : false,
badgeOverlap: badgeBox ? badgeBox.right > status.getBoundingClientRect().left : false,
};
});
return { sizes, overflow: rows.scrollWidth - rows.clientWidth, menuOverflow: element.scrollWidth - element.clientWidth };
});
const tag = `${lang}/${width}/${scale}`;
await menu.screenshot({ path: `perf/shots/model-menu-${lang}-${width}-${scale}.png` });
assert.ok(measured.sizes.some((size) => size.badgeHeight), `${tag}: a model reads images`);
for (const size of measured.sizes) {
assert.ok(size.labelWidth >= 96 * scale, `${tag}: the model name remains readable (${JSON.stringify(size)})`);
if (size.badgeHeight) assert.ok(size.badgeHeight <= size.badgeLine + 1 && !size.badgeClipped && !size.badgeOverlap, `${tag}: the image badge stays whole (${JSON.stringify(size)})`);
}
assert.ok(measured.overflow <= 1 && measured.menuOverflow <= 1, `${tag}: long endpoints do not create horizontal scrolling (${JSON.stringify(measured)})`);
await menu.locator(".mfind").fill("kimi");
const choice = menu.locator(".menu-rows button.mi:not(.plain)").first();
const picked = await choice.getAttribute("data-value");
assert.ok(picked?.includes("kimi"), `${tag}: search still finds the model`);
await choice.click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
await page.keyboard.press("Escape");
await menu.waitFor({ state: "hidden" });
await page.locator(".model-picker").first().click();
await page.waitForFunction((value) => document.querySelector('.studio-model-menu:not([hidden]) button[data-on]')?.dataset.value === value, picked);
assert.equal(await menu.locator(".mfind").inputValue(), "", `${tag}: reopening clears the search`);
await page.keyboard.press("Escape");
await menu.waitFor({ state: "hidden" });
assert.deepEqual(errors, []);
console.log(`PASS model menu ${tag}: names, badges, connection status and endpoints fit`);
await page.close();
}
} finally {
await browser.close();
}
@@ -16,7 +16,6 @@ export const WORKBENCH_ACTIONS: UIAction[] = [
{ id: "workbench.change-open", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.section-fold", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.changes-page", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-find", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-terminal", kind: "view", target: "none", proof: "interaction" },
@@ -133,6 +133,13 @@
:root .composer-menu.studio-model-menu .studio-item-line > .lb { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui)/1.3 var(--ui); }
:root .composer-menu.studio-model-menu .studio-item-meta { flex: none; color: var(--faint); font: var(--w-reg) var(--fs-micro)/1.3 var(--ui); white-space: nowrap; }
:root .composer-menu.studio-model-menu .studio-item-badge { flex: none; white-space: nowrap; }
:root .composer-menu.studio-model-menu .menu-rows .mi.head .lb { min-width: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
:root .composer-menu.studio-model-menu .menu-rows .mi.head .rt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 2px 8px; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-line > .lb { grid-column: 1 / -1; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-meta { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
:root .composer-menu.studio-model-menu .menu-rows .studio-item-badge { grid-column: 2; grid-row: 2; }
:root .composer-menu.studio-model-menu .menu-rows .mi .tx > .ds { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
:root .composer-menu.studio-model-menu .mi[data-on]::after { color: var(--net-ink); }
:root .composer-menu.studio-effort-menu .mi.head { padding: 12px 14px 8px; }
:root .composer-menu.studio-effort-menu .mi.head:first-child .lb { color: var(--text); font: var(--w-semi) var(--fs-ui)/1.3 var(--ui); }
+1 -1
View File
@@ -15,7 +15,7 @@
height: 26px; min-height: 26px; padding: 0 6px; margin: 0 -6px; gap: 7px; box-sizing: border-box; border-radius: var(--r-xs);
background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1 var(--ui);
}
.activity-group[open] > summary { background: transparent; border-bottom: 0; }
.activity-group[open] > summary { background: var(--surface); border-bottom: 0; }
.activity-group > summary:hover { background: var(--overlay); color: var(--text); }
.activity-group > summary .studio-icon { width: 12px; height: 12px; color: var(--faint); }
.activity-group .activity-title { color: inherit; font: inherit; }
@@ -17,4 +17,8 @@ describe("the turn follows the boards", () => {
expect(rule(".rc[data-worked]")).toMatch(/justify-content:\s*flex-end/);
expect(rule(".rc[data-worked] .rc-tick")).toMatch(/width:\s*14px/);
});
it("paints a stuck, open activity heading in the conversation's own colour so rows scroll under it unseen", () => {
expect(rule(".activity-group[open] > summary")).toMatch(/background:\s*var\(--surface\)/);
});
});
@@ -170,8 +170,6 @@ export const WORKBENCH: Messages = {
"workbench.will_send": { zh: "将发送", en: "Will send" },
"workbench.you_do_not_have_permission": { zh: "没有权限读取这个文件。", en: "You do not have permission to read this file." },
"workbench.changes_history_and_files": { zh: "改动、提交历史和文件", en: "Changes, history and files" },
"workbench.find_a_file": { zh: "文件…", en: "Find a file…" },
"workbench.find_a_file_by_name": { zh: "按名字找项目里的文件", en: "Find a file in the project by name" },
"workbench.new_tab_label": { zh: "新标签", en: "New tab" },
"workbench.line_n": { zh: "第 {n} 行", en: "Line {n}" },
"workbench.unsaved": { zh: "未保存", en: "Unsaved" },
@@ -218,6 +218,9 @@ and selected states, while long titles truncate within the remaining width.
## Composer shelf
- Model choices reserve the first line for the model name. Protocol and image
capability share the second line; the capability badge and connection status
keep their width, while long names and endpoints truncate inside the menu.
- A mode is this turn's capability, not a preference, which is why it lives on
the composer and not in settings. A pill is its label's width or it is nothing:
shrinking one squeezed the shelf's own controls, and Chinese wraps.
@@ -11,7 +11,7 @@ const noop = () => {};
it("lists recently opened files and opens the one picked", async () => {
const open = vi.fn();
render(<WorkbenchLauncher changes={0} onFiles={noop} onFind={noop} onBrowser={noop} recent={["src/a.ts", "docs/b.md"]} onOpenFile={open} />);
render(<WorkbenchLauncher changes={0} onFiles={noop} onBrowser={noop} recent={["src/a.ts", "docs/b.md"]} onOpenFile={open} />);
const rows = document.querySelectorAll('[data-action="workbench.launch-recent"]');
expect([...rows].map((r) => r.getAttribute("data-target"))).toEqual(["src/a.ts", "docs/b.md"]);
await userEvent.click(screen.getByText("a.ts"));
@@ -19,14 +19,14 @@ it("lists recently opened files and opens the one picked", async () => {
});
it("shows no recent list when nothing was opened", () => {
render(<WorkbenchLauncher changes={0} onFiles={noop} onFind={noop} onBrowser={noop} recent={[]} onOpenFile={noop} />);
render(<WorkbenchLauncher changes={0} onFiles={noop} onBrowser={noop} recent={[]} onOpenFile={noop} />);
expect(document.querySelector(".workbench-launcher-recent")).toBeNull();
});
it("offers the explorer with the session's change count, finding a file, and the browser, as the board draws a new tab", async () => {
const find = vi.fn();
render(<WorkbenchLauncher changes={4} onFiles={noop} onFind={find} onBrowser={noop} />);
expect([...document.querySelectorAll(".workbench-launcher > button b")].map((b) => b.textContent)).toEqual(["资源管理器4", "文件…", "浏览器"]);
await userEvent.click(screen.getByRole("button", { name: "文件…" }));
expect(find).toHaveBeenCalled();
it("offers one explorer entry with the change count alongside the browser", async () => {
const files = vi.fn();
render(<WorkbenchLauncher changes={4} onFiles={files} onBrowser={noop} />);
expect([...document.querySelectorAll(".workbench-launcher > button b")].map((b) => b.textContent)).toEqual(["资源管理器4", "浏览器"]);
await userEvent.click(screen.getByRole("button", { name: "资源管理器" }));
expect(files).toHaveBeenCalledOnce();
});
@@ -1,8 +1,7 @@
import { t } from "../i18n";
import { StudioIcon } from "./StudioIcon";
/** A new tab: the three things the workbench opens, then what was opened lately. */
export function WorkbenchLauncher({ changes, focus = false, onFiles, onFind, onBrowser, onTerminal, recent = [], onOpenFile }: { changes: number; focus?: boolean; onFiles: () => void; onFind: () => void; onBrowser: () => void; onTerminal?: () => void; recent?: string[]; onOpenFile?: (path: string) => void }) {
export function WorkbenchLauncher({ changes, focus = false, onFiles, onBrowser, onTerminal, recent = [], onOpenFile }: { changes: number; focus?: boolean; onFiles: () => void; onBrowser: () => void; onTerminal?: () => void; recent?: string[]; onOpenFile?: (path: string) => void }) {
return (
<div className="workbench-launcher" role="group" aria-label={t("common.start")}>
<p className="workbench-launcher-title">{t("workbench.open_in_the_workbench")}</p>
@@ -10,10 +9,6 @@ export function WorkbenchLauncher({ changes, focus = false, onFiles, onFind, onB
<StudioIcon name="folder" />
<span><b>{t("workbench.explorer")}{changes > 0 && <small>{changes}</small>}</b><i>{t("workbench.changes_history_and_files")}</i></span>
</button>
<button aria-label={t("workbench.find_a_file")} data-action="workbench.launch-find" onClick={onFind}>
<StudioIcon name="file" />
<span><b>{t("workbench.find_a_file")}</b><i>{t("workbench.find_a_file_by_name")}</i></span>
</button>
<button aria-label={t("workbench.browser")} data-action="workbench.launch-browser" onClick={onBrowser}>
<StudioIcon name="globe" />
<span><b>{t("workbench.browser")}</b><i>{t("workbench.view_web_pages_beside_the")}</i></span>
@@ -208,22 +208,26 @@ describe("WorkbenchPanel", () => {
expect(editor.state.doc.toString()).toContain("local draft");
});
it("leaves new-tab choice through the explorer, finding a file or an existing browser", async () => {
it("uses one explorer entry for browsing and searching, and preserves existing browser tabs", async () => {
const user = userEvent.setup();
const port = new MockPort();
const open = vi.spyOn(port, "browserOpen");
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Existing page", active: true };
const files = vi.spyOn(port, "workspaceFiles");
const { container } = render(<WorkbenchPanel port={port} tabs={[page]} manual shown scheme="light" changes={[{ path: "README.md", status: "M" }]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
const plus = container.querySelector<HTMLButtonElement>(".workbench-new")!;
await user.click(plus);
expect(container.querySelector('[data-action="workbench.launch-find"]')).toBeNull();
await user.click(container.querySelector('[data-action="workbench.launch-files"]')!);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
const search = container.querySelector<HTMLInputElement>(".workbench-search input")!;
await waitFor(() => expect(document.activeElement).toBe(search));
await user.type(search, "README");
await waitFor(() => expect(files).toHaveBeenLastCalledWith("", "README", false));
await user.click(container.querySelector('[data-action="workbench.clear-search"]')!);
await waitFor(() => expect(files).toHaveBeenLastCalledWith("", "", false));
await user.click(plus);
expect(screen.getByRole("tab", { name: "新标签" }).getAttribute("aria-selected")).toBe("true");
await user.click(container.querySelector('[data-action="workbench.launch-find"]')!);
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
await waitFor(() => expect(document.activeElement).toBe(container.querySelector(".workbench-search input")));
await user.click(plus);
await user.click(screen.getByRole("tab", { name: "Existing page" }).querySelector("button")!);
expect(screen.queryByRole("group", { name: "开始" })).toBeNull();
expect(screen.getAllByRole("tab")).toHaveLength(1);
@@ -145,7 +145,10 @@ export function WorkbenchPanel({
const toggleLauncher = () => { supersede(); setLauncher((open) => !open); };
const [cursor, setCursor] = useState<[string, number]>(["", 1]);
const [reread, setReread] = useState(0);
const chooseFiles = () => { supersede(); setLauncher(false); setShowFiles(true); };
const chooseFiles = () => {
supersede(); setLauncher(false); setShowFiles(true);
requestAnimationFrame(() => frame.current?.querySelector<HTMLInputElement>(".workbench-search input")?.focus());
};
const toggleExplorer = () => { supersede(); setLauncher(false); setShowFiles((on) => launcher || !on); };
const [changesOpen, setChangesOpen] = useState(false);
const openChanges = () => {
@@ -619,7 +622,6 @@ export function WorkbenchPanel({
key={String(launcher)} focus={launcher}
changes={changes.length}
onFiles={chooseFiles}
onFind={() => { chooseFiles(); requestAnimationFrame(() => document.querySelector<HTMLInputElement>(".workbench-search input")?.focus()); }}
onBrowser={() => void addBrowser()} onTerminal={() => void terminals.open()}
recent={recentFiles} onOpenFile={openFile}
/>
@@ -40,7 +40,7 @@ it("keeps a terminal's screen mounted but not in front when another tab is chose
const port = new MockPort();
await openTerminalFromLauncher(port);
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
fireEvent.click(document.querySelector('[data-action="workbench.launch-find"]')!);
fireEvent.click(document.querySelector('[data-action="workbench.launch-files"]')!);
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
});
@@ -55,3 +55,19 @@ func TestEachStoreStartsWithAFreshMemoryOfWhatItSaid(t *testing.T) {
t.Fatalf("a second store was silenced by the first: %q / %q", a.String(), b.String())
}
}
// A file below the top level of a skills root that has no description is never
// loaded, so it cannot claim that it "will load"; only a skill that is kept says so.
func TestAnUnkeptNestedFileIsNotWarnedAboutAsALoadedSkill(t *testing.T) {
home := testenv.TempDir(t)
writeSkill(t, home, ".claude/skills/top.md", "---\nname: top\n---\nbody")
writeSkill(t, home, ".claude/skills/pack/README.md", "---\nname: readme\n---\nnotes")
writeSkill(t, home, ".claude/skills/pack/deep/README.md", "---\nname: readme\n---\nnotes")
var stderr bytes.Buffer
st := New(Options{HomeDir: home, DisableBuiltins: true, Stderr: &stderr})
_ = st.List()
got := stderr.String()
if strings.Count(got, "has no description") != 1 || !strings.Contains(got, `"top"`) {
t.Fatalf("want exactly the top-level skill warned about:\n%s", got)
}
}
+3 -3
View File
@@ -707,6 +707,9 @@ func (s *Store) scanDir(dir string, scope Scope, requireFlatMarker bool, depth i
for _, e := range entries {
sk, ok := s.readEntry(dir, scope, requireFlatMarker, e)
if ok {
if depth == 1 && strings.TrimSpace(sk.Description) == "" && !s.suppressWarnings {
s.diagnose("warning: skill %q at %s has no description: — it will load but won't appear in the skills index\n", sk.Name, sk.Path)
}
if depth == 1 || strings.TrimSpace(sk.Description) != "" {
*out = append(*out, sk)
}
@@ -818,9 +821,6 @@ func (s *Store) parseSkill(path, stem string, scope Scope, requireSkillMarker bo
name, path, misplaced, misplaced)
}
desc := strings.TrimSpace(fm[skillFrontmatterDescription])
if desc == "" && !s.suppressWarnings {
s.diagnose("warning: skill %q at %s has no description: — it will load but won't appear in the skills index\n", name, path)
}
sk := Skill{
Name: name,
Description: desc,
+12
View File
@@ -0,0 +1,12 @@
## 修复
- 优化模型选单的拥挤布局:模型名单独一行,协议和「读图」标签放在下一行,连接状态保持完整;长服务商地址不再撑出横向滚动条(#15040,关联 #15012)。
- 合并工作台新标签页中重复的「资源管理器」和「文件…」入口;打开资源管理器即可直接搜索文件(#15040)。
---
<!-- lang:en -->
## Fixed
- Model names have their own line; protocol and image badges sit below without squeezing connection status. Long provider endpoints stay within the menu (#15040; refs #15012).
- The workbench's duplicate Explorer and Find a file entries are merged. Opening Explorer focuses the file search (#15040).
@@ -0,0 +1,10 @@
## 修复
- 修复对话向下滚动、展开的「执行过程」标题吸在顶部时,背景是透明的,导致标题和下面滚上来的执行内容叠在一起、两行文字重叠显示的问题:吸顶的标题现在用对话区的底色盖住它下面的内容(#15044)。
---
<!-- lang:en -->
## Fixed
- When an open "Work" heading stuck to the top while the conversation scrolled, its background was transparent, so the rows scrolling beneath it showed through and two lines of text overlapped. The stuck heading is now painted in the conversation's own colour (#15044).
@@ -0,0 +1,10 @@
## 修复
- 修复技能目录里的文档文件(比如 `skills/某技能/README.md`、`skills/某技能/子目录/README.md`)被误报成"没有描述,会加载但不会出现在技能索引里"的警告弹窗:这些嵌套的文件本来就不会被当成技能,现在只对真正保留下来的技能说这句话(#15045)。
---
<!-- lang:en -->
## Fixed
- Documentation files inside a skill folder, such as `skills/some-skill/README.md` or one a level deeper, no longer raise the warning that a skill "has no description: it will load but won't appear in the skills index". Such nested files are never loaded as skills; the warning now appears only for a skill that is kept (#15045).