From 327536548942da0b8520d814de22185a0db9fe6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:09:53 +0800 Subject: [PATCH 01/21] fix(docs): make site builds idempotent --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 4 +- .../2026-08-20-doc-site-raw-markdown-twins.md | 4 +- ...26-08-20-doc-site-raw-markdown-twins.zh.md | 4 +- package.json | 4 +- scripts/project-doc-site.spec.ts | 43 ++++++++++++++ website/AGENTS.md | 2 + website/build.ts | 57 +++++++++++++++++++ 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 website/build.ts diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml index b6cb38880f..2b2e377275 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md -2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2 -2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 +2026-08-20-doc-site-raw-markdown-twins.md: 85f6cc957d5f3a1675a38abc4675e76448ffb5f5 +2026-08-20-doc-site-raw-markdown-twins.zh.md: df6f0780223dc10fc9b5832f97a360e08e68b46b diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md index 5b45657d13..85f6cc957d 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure. -An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission. +An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. + +Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and removes that directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission. `llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label]():
` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point. diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md index 1730c9e23e..df6f078022 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -14,7 +14,9 @@ Status: implemented 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 -index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。 +index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。 + +每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并在打包前删除该目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 diff --git a/package.json b/package.json index 65b9786b40..bec66ae4c9 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments", + "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", + "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index df1c4dc4a4..5d58509fab 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -9,6 +9,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts' import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage, @@ -70,6 +71,48 @@ describe('website source layout', () => { }) }) +describe('documentation site build', () => { + it.each([ + { mode: 'SPA', mpa: false, expectedMpa: undefined }, + { mode: 'MPA', mpa: true, expectedMpa: 'true' }, + ])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-')) + roots.push(root) + const outDir = join(root, '.dist') + const stale = join(outDir, 'stale.md') + const fresh = join(outDir, 'index.html') + mkdirSync(outDir) + writeFileSync(stale, 'stale\n') + + const options = docSiteBuildOptions(root, mpa) + expect(options.mpa).toBe(expectedMpa) + expect(existsSync(stale)).toBe(true) + await options.onAfterConfigResolve?.({ outDir } as never) + expect(existsSync(outDir)).toBe(false) + mkdirSync(outDir) + writeFileSync(fresh, 'fresh\n') + + expect(readFileSync(fresh, 'utf8')).toBe('fresh\n') + }) + + it('refuses to remove the site root or an outside directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-')) + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-')) + roots.push(root, outside) + writeFileSync(join(root, 'keep'), 'root\n') + writeFileSync(join(outside, 'keep'), 'outside\n') + + expect(() => { + cleanDocSiteOutput(root, root) + }).toThrow('must be a child of site root') + expect(() => { + cleanDocSiteOutput(root, outside) + }).toThrow('must be a child of site root') + expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n') + expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n') + }) +}) + describe('publishableImage', () => { it('accepts a regular file inside the repository', () => { const { root } = fixture() diff --git a/website/AGENTS.md b/website/AGENTS.md index 217417660b..65ca9d28e0 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -10,6 +10,8 @@ Keep canonical prose and generated catalogs in their owning `docs/` tier, then e The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`. +Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them. + The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree. Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. diff --git a/website/build.ts b/website/build.ts new file mode 100644 index 0000000000..dad6e4a5cd --- /dev/null +++ b/website/build.ts @@ -0,0 +1,57 @@ +/** Production documentation-site build with project-owned output preparation. */ + +import { rmSync } from 'node:fs' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { build } from 'vitepress' + +const websiteRoot = resolve(import.meta.dirname) +type DocSiteBuildOptions = NonNullable[1]> + +/** + * Remove one documentation build output without permitting the site root or an outside path. + * @param siteRoot - VitePress site root that owns the output. + * @param outDir - Resolved VitePress output directory. + * @throws When `outDir` is not a proper child of `siteRoot`. + */ +export function cleanDocSiteOutput(siteRoot: string, outDir: string): void { + const root = resolve(siteRoot) + const output = resolve(outDir) + const child = relative(root, output) + if (child === '' || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) { + throw new Error(`build-doc-site: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`) + } + rmSync(output, { recursive: true, force: true }) +} + +/** + * Create VitePress build options that remove the resolved output directory before bundling. + * @param siteRoot - VitePress site root to build. + * @param mpa - Whether to use VitePress's multi-page application build. + * @returns VitePress options with project-owned output preparation. + */ +export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions { + const root = resolve(siteRoot) + return { + ...mpa ? { mpa: 'true' } : {}, + onAfterConfigResolve(siteConfig) { + cleanDocSiteOutput(root, siteConfig.outDir) + }, + } +} + +async function buildDocSite(siteRoot: string, mpa: boolean): Promise { + const root = resolve(siteRoot) + await build(root, docSiteBuildOptions(root, mpa)) +} + +function parseMpa(args: string[]): boolean { + if (args.length === 0) return false + if (args.length === 1 && args[0] === '--mpa') return true + throw new Error(`build-doc-site: expected no arguments or --mpa, got ${JSON.stringify(args)}.`) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) { + await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2))) +} From f7890f591a6e2ff681a34d1879968a77f963dd3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:31:08 +0800 Subject: [PATCH 02/21] fix(agent-presets): make a preset's failures legible where they happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery proved only that a composition parsed, so a preset naming a package a later rename took away kept a healthy card and its place in every picker until a person switched to it. It now resolves each row it can prove will start, reading the package off disk and falling back to the resolver only for names that look absent — the resolver costs a synchronous hooks-thread round-trip under the source launch's tsx hook, which the walk avoids for every row it clears. The mount diagnostic followed `AggregateError.errors` but never a cause, so a group that failed on two rows named neither. It now follows a cause that carries more than its own message. A refused switch left the chip's label snapping back with no account of why, which is the only account there can be for a preset that resolves and then refuses. It announces through the shared Toast, which gained a caller-set hold for a cause that names packages and rows. --- ...8-26-preset-health-resolves-rows.i18n.yaml | 6 + .../2026-08-26-preset-health-resolves-rows.md | 73 +++++++ ...26-08-26-preset-health-resolves-rows.zh.md | 73 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 49 ++++- .../agent-preset-selection/menu.expected.md | 1 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 4 +- packages/client/ui-agent-preset/README.zh.md | 4 +- .../src/client/AgentPresetSeat.tsx | 137 ++++++++----- .../src/client/AgentPresetSection.module.css | 50 ++++- .../src/client/AgentPresetSection.tsx | 23 ++- .../ui-agent-preset/src/client/locales.ts | 4 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../tests/apply.client.spec.ts | 24 +++ .../tests/components.client.spec.tsx | 60 +++++- .../tests/section.client.spec.tsx | 16 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Toast.module.css | 10 +- packages/client/ui-primitives/src/Toast.tsx | 28 ++- .../ui-primitives/tests/toast.client.spec.tsx | 18 ++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 4 +- packages/preset/agent-presets/README.zh.md | 4 +- packages/preset/agent-presets/package.json | 1 + .../preset/agent-presets/src/discovery.ts | 182 +++++++++++++++++- packages/preset/agent-presets/src/index.ts | 26 ++- packages/preset/agent-presets/src/mount.ts | 46 ++++- .../preset/agent-presets/src/specifier.ts | 45 +++++ .../agent-presets/tests/authoring.spec.ts | 7 +- .../agent-presets/tests/discovery.spec.ts | 161 ++++++++++++++-- .../tests/fixtures/plugins/throws.js | 9 + .../fixtures/user/broken/agent.cordis.yml | 6 +- .../user/nested-broken/agent.cordis.yml | 16 ++ .../fixtures/user/two-broken/agent.cordis.yml | 14 +- .../preset/agent-presets/tests/mount.spec.ts | 38 +++- .../agent-presets/tests/shipped-root.spec.ts | 8 +- pnpm-lock.yaml | 3 + 39 files changed, 1041 insertions(+), 145 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.zh.md create mode 100644 packages/preset/agent-presets/src/specifier.ts create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/throws.js create mode 100644 packages/preset/agent-presets/tests/fixtures/user/nested-broken/agent.cordis.yml diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml new file mode 100644 index 0000000000..48dbb0f806 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md +2026-08-26-preset-health-resolves-rows.md: 1615b2b86bee27a282357e1d5f023797672cb9b5 +2026-08-26-preset-health-resolves-rows.zh.md: ea287ae8204aa1730d80199b0f4e86e3cf7b42a3 diff --git a/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md new file mode 100644 index 0000000000..1615b2b86b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-preset-health-resolves-rows.md @@ -0,0 +1,73 @@ +# Agent Note: Preset health resolves the rows it can prove will start + +Status: implemented + +English | [中文](2026-08-26-preset-health-resolves-rows.zh.md) + +## Problem + +A preset the roster listed as healthy could still be impossible to compose. Discovery's health check proved the composition parsed in the loader dialect and held named rows, and deliberately stopped there — it resolved no plugin name and applied no config. + +`broken` is load-bearing, though, not a card decoration. `presetOptions` drops a broken row from the session pickers so a chooser never defers the discovery to a failed session start, and `resolveMountable` refuses one before spending a mount. Everything downstream therefore reads "not broken" as "will compose". + +The gap surfaced when the [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) renamed packages under the pre-release stance. In-repo references moved with it; a preset authored under `/.agent-presets` did not, and one naming `@deepseek-ai/dsh-workspace-context` kept its healthy card, kept its place in the picker, and failed only when a person switched to it. A row naming a package a later release renamed or uninstalled is how an authored preset actually rots, and it was exactly the class the check excluded. + +The failure it did produce named less than it knew. The Loader's per-row wrapper builds a plain `Error` whose message ends with `cause.message` and keeps the cause only as `error.cause`. A group that fails on two rows therefore arrives as one wrapped row whose message is `failed to apply loader entry (cordis:group): loader entries failed to apply`, with the two real reasons reachable through `cause.errors` alone. The mount diagnostic flattened `AggregateError.errors` and never followed `cause`, so it ended at that line and named neither row. + +## Decision + +**Discovery resolves each row it can prove will start, and imports nothing.** The resolve pass runs after the shape check in `packages/preset/agent-presets/src/discovery.ts`, so a malformed composition still answers with the shape reason. A package name is looked up on disk — Node's own upward `node_modules` walk, stopping at `/package.json` — and only a name that finds nothing there is confirmed through `import.meta.resolve`, whose refusals are then remembered for the process. A preset-relative or absolute specifier is statted instead, because `import.meta.resolve` only joins URLs for those and a preset shipping a file that was deleted would otherwise pass. Nothing is evaluated either way. + +The disk lookup is the fast path because the resolver is not one: a registered ESM loader hook turns every `import.meta.resolve` call into a synchronous round-trip to the hooks thread. Under the `tsx` hook the source launch installs, that measured 2ms for a hit and 5ms for a miss against 0.055ms and 0.032ms on bare Node, which put 238ms of resolver time into each roster read. The walk answers the same 135 rows in 0.7ms. Keeping the resolver for names the walk cannot find leaves a read paying for the failures it reports rather than for every row it clears, and keeps a package only a loader can resolve — through tsconfig paths, or an import map — from being called broken. A Node builtin short-circuits ahead of both. + +The refusal memo sits behind the walk, never in front of it, so a package installed since is found on disk and a recorded refusal cannot go stale in the direction that matters. + +**One classifier decides where a row resolves.** `src/specifier.ts` owns the split — `cordis:` builtin, preset-relative, absolute file, package name — and both the mount's import override and discovery's check read it. A row discovery resolved from one base while the mount imported it from another would be reported healthy and then fail to load. + +**A row that may never start is skipped.** `disabled` is the one entry field the [Loader interpolates](2026-08-11-loader-entry-disabled-interpolation.md): a `!!js` expression evaluates against the loader context at mount time, which discovery cannot do from a file. A row carrying anything but an absent, null, or `false` value is left unchecked, and a disabled group takes its children with it. Every shipped preset gates its shell rows this way, so this is the common shape, not a corner. + +**The harness base is a required argument.** `discoverPresets(roots, harnessBase)` and `scanRoot(root, harnessBase)` take it; `AgentPresets` reads `ctx.baseUrl` once in its constructor and throws when it is absent. The base is what makes the question answerable at all — the same package name fails from a preset's own directory and resolves from the installed harness — so an optional one would silently restore the state this check exists to end. + +**The mount diagnostic follows a cause that carries more than its message.** `mountDetail` reads branches from `AggregateError.errors`, or from `error.cause.errors` when the cause is an `AggregateError`; a plain cause chain is already flattened into the message and is not followed, which would print every line twice. Nested branches indent under the row that owns them. + +**The client puts the reason on the badge.** The card face keeps the preset's own description, because a package specifier tells a chooser nothing they can act on there. The host's reason is the badge's tooltip for a pointer, and a visually hidden `role="alert"` node carries it to assistive technology — the card body is disabled when a preset is broken, so it leaves the tab order and the tooltip has no keyboard path. + +**A refused switch says why, where it was refused.** The chip's own label reverts to the preset the session still runs, so without a word the pick simply appears not to have happened. It announces through the shared `Toast`, over the composer column, the way the model picker beside it already reports a rejected selection. Only a pick a person just made is announced — the applier also runs when a session becomes current, and a banner over that would report a refusal nobody asked for. The banner holds for eight seconds rather than the primitive's three, because it carries a cause that names packages and rows; `Toast` gained a `holdMs` for that, which also retired the hazard of a hold constant the stylesheet had to be kept in step with by hand. + +The wire already separated the two texts this needs: `message` wraps the cause in the roster's own "preset X failed to mount" frame, while `details.reason` holds the cause alone. A surface that names the preset itself takes the second, or it says the preset twice. + +## Alternatives considered + +**Check when a preset is selected rather than when the roster is listed.** Rejected. The pickers filter on `broken` before anyone selects, so a preset only checked at selection is still offered, and the reported failure still arrives after the click — the original complaint, relocated. The roster row is where every consumer already reads the verdict. + +**Keep the base optional and skip the check without one.** Rejected. Its failure mode is precisely the bug being fixed, delivered with no signal: healthy cards for presets that cannot compose. `ctx.baseUrl` is set on the root before any scoped context derives from it, so the throw is an assertion about something that does not happen rather than a branch with runtime cost. + +**Import each row instead of resolving it.** Rejected. Importing runs module top-level code on every roster read, which is a side effect a picker must not have, and it is the mount's job — a plugin that throws on apply or waits forever for a service still fails at the first session, by design. + +**Resolve every row through `import.meta.resolve`.** Shipped first and reverted on measurement: correct, and 445ms per roster read, which the client's three concurrent reads turned into 2.45 seconds apiece — the settings section visibly stalled. The resolver is the authority on what imports, but asking it about rows that are plainly installed pays a hooks-thread round-trip for each one. + +**Cache the whole of `compositionProblem` on the existing `CompositionStamp`.** Rejected as the answer to the cost: it would have made repeat reads free while leaving the first read of every edited composition at full price, and it keys resolution on the composition file, which does not change when an install does. The walk removed the cost instead, so nothing needs the stamp. + +**Send the switch failure to the roster card instead of a banner.** Rejected: the card is exactly where the failures that reach a mount are invisible. A composition whose rows all resolve is reported healthy, so "see the settings page for the reason" points at a card that says the preset is fine. + +**Report only the first unresolvable row, matching the shape check.** Rejected. A parse failure can cascade, so naming one is honest there; unresolvable names are independent facts all knowable at once, and reporting them one reload at a time is the avoidable part. + +**Follow `error.cause` unconditionally in `mountDetail`.** Rejected. The Loader's wrapper already appends `cause.message` to the message it builds, so a plain chain would render every line twice. An `AggregateError` cause is the one shape whose detail the message drops. + +**Keep rendering the reason on the card face.** Rejected. The reason names package specifiers and paths, and a picker card that shows them in place of the preset's description trades what a chooser needs for what a fixer needs — while the fixer's copy is one hover away either way. + +**Reuse the icon row's `data-tip` pseudo-element for the tooltip.** Rejected once measured: generated content joins an element's accessible text, so the card's aria snapshot grew a second verbatim copy of a reason the alert already carried. A real `aria-hidden` element keeps exactly one accessible copy — and the existing tooltip is one `nowrap` line sized for an icon label, while this one names package specifiers one per line. + +**Make the badge a focusable control so the tooltip has a keyboard path.** Rejected for now. The badge sits inside the card's own ` + <> + { setOpen(false) }} + items={state.options.map((option) => { + const text = presetDisplayText(option, t) + return { + id: option.id, + // Name and description together: the id alone never says what a + // preset does, which is why the roster carries display copy. + label: ( + + {text.name} + {text.description ?? t('noDescription')} + + ), + } + })} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + const picked = state.options.find(option => option.id === id) + // The fallback is for the row shape `find` cannot promise; the menu's + // items ARE `state.options`, so an emitted id is always one of them. + /* v8 ignore next */ + const name = picked === undefined ? id : presetDisplayText(picked, t).name + void select(id).then((refusal) => { + // Announced only for a pick a person just made: `apply()` also runs + // when a session becomes current, and a banner over that would + // report a refusal nobody asked for. + if (refusal === undefined) return + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) }) + }) + }} + align="start" + portal + anchor={( + + )} + /> + {toast !== null && ( + } + holdMs={REFUSAL_HOLD_MS} + // The composer card, which is the content column this chip sits + // above — not an ancestor of it, so the lookup is a page query + // rather than `closest`. Absent, the banner centers on the window, + // which is off-center whenever the sidebar is open. + anchor={ + seatRef.current?.closest('[data-composer-card]') + ?? document.querySelector('[data-composer-card]') + } + onDone={() => { setToast(null) }} + /> )} - /> + ) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 0a8d2fa8a3..3b1378d3a3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -94,12 +94,49 @@ color: var(--dsw-alias-bg-layer-3); } -/* The discovery-reported reason, verbatim: it names the file and the fix. */ -.cardBrokenReason { - font-size: 12px; +/* The discovery-reported reason, verbatim: it names the rows and the fix. + A real element rather than the icon row's `data-tip` pseudo-element, for two + reasons: generated content joins the card's accessible text, where this would + repeat what the alert already carries, and that tooltip is one `nowrap` line + for an icon label while this names package specifiers one per line. */ +.brokenTip { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 1; + width: max-content; + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + font-weight: 400; line-height: 1.5; - color: var(--dsw-alias-state-error-primary); + text-align: left; + white-space: pre-line; overflow-wrap: anywhere; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.brokenBadge:hover .brokenTip { + opacity: 1; +} + +/* The same reason, for assistive technology only. The card body is disabled + when a preset is broken, so it leaves the tab order and the badge's tooltip + has no keyboard or screen-reader path; this node is that path. Sighted + pointer users read the badge instead, which keeps a picker card showing the + preset's own description rather than a package specifier. */ +.cardBrokenReason { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } /* The card body is the control that picks the preset. */ @@ -132,6 +169,11 @@ display: flex; align-items: center; gap: 8px; + /* Anchors the broken badge's tooltip: the badge itself stays unpositioned so + its `::after` resolves against the card's own width instead of against a + badge that sits partway across it. A tooltip grown from the badge would + run past the card, and past the section for a card in the last column. */ + position: relative; } .cardName { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 59ccb19226..60056e6b7a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -257,7 +257,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is - disabled and the card says why instead of offering it. */} + disabled; the reason rides the badge rather than the card + face, which stays the preset's own description. */}