From 943d99e64c48d777156b60973cdadb5f123c4865 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Sat, 18 Jul 2026 16:36:46 -0400 Subject: [PATCH 01/11] AI Assistant skill menu: one mixed chooser for cards + skill files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two skill-attach buttons into a single action that opens the mixed cards + files chooser, and retire the file-tree path for skills. - One `Choose a Skill to add` button opens `chooseCard({ includeFiles: true })` with a base filter that spans both kinds: `any` of the Skill card type and MarkdownDef files whose `kind` is `skill`. Each pick is routed by its kind — a card to `onChooseCard`, a file to `onChooseSkillMarkdown`. - Already-attached skills are excluded by `not: { eq: { id } }` clauses built client-side from the menu's own skill list: card skills are excluded immediately, and file skills once their rows carry `id` in the search doc (Phase 1 reindex), so this ships without waiting on a deployed reindex. - The separate skill-file button and its `chooseFile`-based attach are removed. `chooseFile` / `FileChooserModal` stay for their other consumers (message file attach, Playground, markdown-embed). Tests: the single button attaches a skill card and a skill markdown file; already-attached card and markdown skills are excluded; the retired skill-file button is gone; canceling the chooser re-enables the add button. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-assistant/skill-menu/index.gts | 97 ++++++++----------- .../ai-assistant-panel/skills-test.gts | 93 ++++++++++++------ 2 files changed, 102 insertions(+), 88 deletions(-) diff --git a/packages/host/app/components/ai-assistant/skill-menu/index.gts b/packages/host/app/components/ai-assistant/skill-menu/index.gts index 35815ead8c..a14a499ac2 100644 --- a/packages/host/app/components/ai-assistant/skill-menu/index.gts +++ b/packages/host/app/components/ai-assistant/skill-menu/index.gts @@ -11,18 +11,16 @@ import pluralize from 'pluralize'; import { Button } from '@cardstack/boxel-ui/components'; -import { baseRRI, skillCardRef } from '@cardstack/runtime-common'; -import { chooseCard, chooseFile } from '@cardstack/runtime-common'; +import { baseRRI, chooseCard, skillCardRef } from '@cardstack/runtime-common'; import SkillToggle from '@cardstack/host/components/ai-assistant/skill-menu/skill-toggle'; import PillMenu from '@cardstack/host/components/pill-menu'; import type { RoomSkill } from '@cardstack/host/resources/room'; -import type { FileDef } from '@cardstack/base/file-api'; - // A skill expressed as a markdown file is a `MarkdownDef` whose frontmatter -// declares `boxel.kind: skill`. The file chooser is scoped to exactly those. +// declares `boxel.kind: skill`. One branch of the mixed chooser's base filter +// selects exactly those. const markdownDefRef = { module: baseRRI('markdown-file-def'), name: 'MarkdownDef', @@ -75,8 +73,8 @@ export default class AiAssistantSkillMenu extends Component { class='attach-button' @kind='primary' @size='extra-small' - {{on 'click' this.attachSkillCard}} - @disabled={{this.doAttachSkillCard.isRunning}} + {{on 'click' this.attachSkill}} + @disabled={{this.doAttachSkill.isRunning}} @loading={{this.isAttachingSkill}} data-test-pill-menu-add-button > @@ -86,21 +84,6 @@ export default class AiAssistantSkillMenu extends Component { Choose a Skill to add {{/if}} - + @service declare private realmServer: RealmServerService; + @tracked private isExpanded = false; @tracked private isAttachingSkill = false; @@ -196,6 +200,12 @@ export default class AiAssistantSkillMenu extends Component { ...exclusions, ], }, + // Scope to the user's own workspaces. The mixed chooser (`includeFiles`) + // renders a tile per matching row; without a realm scope it fans out + // across every server realm — including large shared realms — and the + // intended skills may never finish rendering. Skills a user attaches + // come from their workspaces. + realms: this.realmServer.userRealmIdentifiers, }; let chosen = await chooseCard(query, { includeFiles: true }); if (!chosen) { From e10622e0becfedde647473cf49ab1711b4e29519 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 20 Jul 2026 15:24:31 -0400 Subject: [PATCH 03/11] Skills test: populate user realms so the skill menu's realm scope applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill menu scopes its mixed chooser to `realmServer.userRealmIdentifiers`. Under mock matrix that list stayed empty (only `activeRealms` was set), so the scope was a no-op and the chooser fanned out across every server realm — including the large shared skills realm — and the intended skill tiles never rendered in time. Set `activeRealmServers` in the mock-matrix setup (as the workspace-chooser integration test does) so the user realm list populates with the test realm, exercising the same scoped path the skill menu takes in production. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-assistant-panel/skills-test.gts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts b/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts index 1cdaf32d70..bae6fddc7f 100644 --- a/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts +++ b/packages/host/tests/integration/components/ai-assistant-panel/skills-test.gts @@ -10,6 +10,7 @@ import { REPLACE_MARKER, SEARCH_MARKER, SEPARATOR_MARKER, + ensureTrailingSlash, rri, skillCardRef, } from '@cardstack/runtime-common'; @@ -22,6 +23,7 @@ import { } from '@cardstack/runtime-common/matrix-constants'; import OperatorMode from '@cardstack/host/components/operator-mode/container'; +import ENV from '@cardstack/host/config/environment'; import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; @@ -56,6 +58,8 @@ import { setupRenderingTest } from '../../../helpers/setup'; import type { FileDef } from '@cardstack/base/file-api'; +const testRealmServerURL = ensureTrailingSlash(ENV.realmServerURL); + module('Integration | ai-assistant-panel | skills', function (hooks) { const realmName = 'Operator Mode Workspace'; let loader: Loader; @@ -79,6 +83,11 @@ module('Integration | ai-assistant-panel | skills', function (hooks) { let mockMatrixUtils = setupMockMatrix(hooks, { loggedInAs: '@testuser:localhost', activeRealms: [testRealmURL], + // Populate the user's realm list so `realmServer.userRealmIdentifiers` is + // non-empty — the skill menu scopes its mixed chooser to those realms, and + // without this the search fans out across every server realm (the large + // shared skills realm included) and the intended tiles never render. + activeRealmServers: [testRealmServerURL], autostart: true, now: (() => { // deterministic clock so that, for example, screenshots From 2625a14bb073fe3616a892e2f955de06f559e4f6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 20 Jul 2026 17:14:35 -0400 Subject: [PATCH 04/11] Mixed search: don't collapse a compound base filter when a type is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill menu's base filter is `any: [{ type: skillCardRef }, { on: markdownDefRef, eq: { kind: 'skill' } }]`. When the type picker seeds a selected type, `buildSearchQuery` stripped the base filter's type constraint — but `stripTypeFromFilter` collapsed the `any` (dropping the `skillCardRef` branch) down to the lone `{ on, eq }` markdown branch, which then AND-ed into the top-level `every`. Every row was required to be a `kind: 'skill'` markdown file, so skill *cards* were excluded and only markdown skills rendered. Only strip a simple single-type base filter (which is all the optimization was meant for); keep a compound base filter intact so its alternation survives. Same guard applied to `buildRecentsQuery`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/utils/card-search/query-builder.ts | 27 ++++++++++----- .../unit/card-search-query-builder-test.ts | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/host/app/utils/card-search/query-builder.ts b/packages/host/app/utils/card-search/query-builder.ts index ff2d77162c..8129c33e48 100644 --- a/packages/host/app/utils/card-search/query-builder.ts +++ b/packages/host/app/utils/card-search/query-builder.ts @@ -210,12 +210,18 @@ export function buildSearchQuery( const searchTerm = searchKey?.trim() || undefined; let filters: Filter[]; if (baseFilter) { - // When typeFilter is present, strip the baseFilter's (parent) type - // constraint as redundant — the picked subtype already implies it. Safe - // because the type picker only offers subtypes of the baseFilter type. - const effectiveBaseFilter = typeFilter - ? stripTypeFromFilter(baseFilter) - : baseFilter; + // When typeFilter is present, strip a *simple* baseFilter's type constraint + // as redundant — the picked subtype already implies it. Only safe for a + // single-type base filter. A compound base filter (e.g. the skill menu's + // `any: [{ type: skillCardRef }, { on: markdownDefRef, eq: { kind: 'skill' } + // }]`) must stay intact: stripping the type branch collapses the `any` and + // promotes the sibling field constraint into a required top-level `every` + // clause — which would wrongly exclude the other branch's kind (skill cards + // vanish, only markdown skills survive). + const effectiveBaseFilter = + typeFilter && isCardTypeFilter(baseFilter) + ? stripTypeFromFilter(baseFilter) + : baseFilter; filters = [ ...(effectiveBaseFilter ? [effectiveBaseFilter] : []), ...(typeFilter ? [typeFilter] : []), @@ -267,9 +273,12 @@ export function buildRecentsQuery( : undefined; let filters: Filter[]; if (baseFilter) { - const effectiveBaseFilter = typeFilter - ? stripTypeFromFilter(baseFilter) - : baseFilter; + // Only strip a simple single-type base filter (see `buildSearchQuery`); + // a compound base filter must stay intact or the `any` collapses. + const effectiveBaseFilter = + typeFilter && isCardTypeFilter(baseFilter) + ? stripTypeFromFilter(baseFilter) + : baseFilter; filters = [ ...(effectiveBaseFilter ? [effectiveBaseFilter] : []), ...(typeFilter ? [typeFilter] : []), diff --git a/packages/host/tests/unit/card-search-query-builder-test.ts b/packages/host/tests/unit/card-search-query-builder-test.ts index 66cca4bea7..a5754fe845 100644 --- a/packages/host/tests/unit/card-search-query-builder-test.ts +++ b/packages/host/tests/unit/card-search-query-builder-test.ts @@ -189,6 +189,39 @@ module('Unit | card-search/query-builder', function () { }); }); + test('a compound `any` base filter stays intact when types are selected (not collapsed)', function (assert) { + // The skill menu's base filter spans a card type and a file-field + // constraint. Regression: stripping the type branch collapsed the `any` + // to just the `{ on, eq }` branch and promoted it into a required `every` + // clause, so skill cards were excluded (only markdown skills survived). + let authorRef = { + module: 'http://test-realm/test/author' as RealmResourceIdentifier, + name: 'Author', + }; + let markdownRef = { + module: + 'https://cardstack.com/base/markdown-file-def' as RealmResourceIdentifier, + name: 'MarkdownDef', + }; + let baseFilter: Filter = { + any: [{ type: authorRef }, { on: markdownRef, eq: { kind: 'skill' } }], + }; + let query = buildSearchQuery('', SORT_AZ, baseFilter, [ + `${authorRef.module}/${authorRef.name}`, + `${markdownRef.module}/${markdownRef.name}`, + ]); + assert.deepEqual(query, { + filter: { + every: [ + // Kept whole — both branches survive so cards AND files match. + baseFilter, + { any: [{ type: authorRef }, { type: markdownRef }] }, + ], + }, + sort: SORT_AZ.sort, + }); + }); + test('cardsOnly adds no filter anchor — the card scope is pinned on the wire', function (assert) { let query = buildSearchQuery('', SORT_AZ, undefined, undefined, { cardsOnly: true, From 691ecd34d6a0cf43ed3fc43b7b9e37fa87d055c5 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 20 Jul 2026 17:59:10 -0400 Subject: [PATCH 05/11] Skill chooser: NULL-safe skill exclusions; fix any-collapse in strip helper Two review fixes: - The client-built already-attached exclusions were `not: { eq: { id } }`, which drop any row whose `id` is absent (`NOT (NULL = X)` is NULL under three-valued logic). On a realm not yet carrying the stamped file `id`, that hid *every* skill-file row whenever any skill was attached. Wrap each in `any: [{ eq: { id: null } }, { not: { eq: { id } } }]` so a null id keeps the row while a present-and-matching id still excludes it. - Move the `any`-collapse guard into `stripTypeFromFilter` itself: never strip within an `any` (dropping/collapsing a branch changes which rows match), so the invariant holds for any caller. Drops the band-aid `isCardTypeFilter` guards at the two call sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-assistant/skill-menu/index.gts | 18 +++++--- .../app/utils/card-search/query-builder.ts | 41 ++++++++----------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/packages/host/app/components/ai-assistant/skill-menu/index.gts b/packages/host/app/components/ai-assistant/skill-menu/index.gts index e104de8075..829f5233e0 100644 --- a/packages/host/app/components/ai-assistant/skill-menu/index.gts +++ b/packages/host/app/components/ai-assistant/skill-menu/index.gts @@ -179,14 +179,20 @@ export default class AiAssistantSkillMenu extends Component { // (`includeFiles`) surfaces both in a single list and tags each pick with its // kind, which routes the result to the matching attach callback. private doAttachSkill = restartableTask(async () => { - // Exclude already-attached skills. The `not: { eq: { id } }` clauses are - // built client-side from the menu's own skill list, so card skills are - // excluded immediately; file skills are excluded once their rows carry - // `id` in the search doc (Phase 1 reindex), and the feature ships without - // waiting on that reindex. + // Exclude already-attached skills, matched client-side against the menu's + // own skill list by `id`. Card skills are excluded immediately; file skills + // once their rows carry `id` in the search doc (Phase 1 reindex), and the + // feature ships without waiting on that reindex. + // + // The exclusion must be NULL-safe: a bare `not: { eq: { id } }` drops any + // row whose `id` is absent, because negated `eq` compiles to `NOT (id = X)` + // and `NOT (NULL = X)` is NULL (excluded) under three-valued logic. On a + // realm not yet carrying the stamped `id`, every skill-file row lacks `id`, + // so that would hide *all* skill files whenever any skill is attached. The + // `{ eq: { id: null } }` branch (an `IS NULL` test) keeps those rows. let exclusions = this.args.skills?.map((skill: RoomSkill) => ({ - not: { eq: { id: skill.cardId } }, + any: [{ eq: { id: null } }, { not: { eq: { id: skill.cardId } } }], })) ?? []; let query = { filter: { diff --git a/packages/host/app/utils/card-search/query-builder.ts b/packages/host/app/utils/card-search/query-builder.ts index 8129c33e48..2b53f371ce 100644 --- a/packages/host/app/utils/card-search/query-builder.ts +++ b/packages/host/app/utils/card-search/query-builder.ts @@ -60,12 +60,12 @@ function stripTypeFromFilter(filter: Filter): Filter | undefined { return on ? { every: children, on } : { every: children }; } if (isAnyFilter(filter)) { - const children = filter.any - .map((f) => stripTypeFromFilter(f)) - .filter((f): f is Filter => f !== undefined); - if (children.length === 0) return undefined; - if (children.length === 1) return on ? { ...children[0], on } : children[0]; - return on ? { any: children, on } : { any: children }; + // Never strip within an `any`: the branches are alternatives, so dropping + // a branch's type (or collapsing a stripped-empty branch) changes which + // rows match. e.g. the skill menu's `any: [{ type: skillCardRef }, { on: + // markdownDefRef, eq: { kind: 'skill' } }]` would collapse to the markdown + // branch alone, excluding skill cards. Keep the alternation intact. + return filter; } if (isNotFilter(filter)) { const inner = stripTypeFromFilter(filter.not); @@ -210,18 +210,14 @@ export function buildSearchQuery( const searchTerm = searchKey?.trim() || undefined; let filters: Filter[]; if (baseFilter) { - // When typeFilter is present, strip a *simple* baseFilter's type constraint - // as redundant — the picked subtype already implies it. Only safe for a - // single-type base filter. A compound base filter (e.g. the skill menu's - // `any: [{ type: skillCardRef }, { on: markdownDefRef, eq: { kind: 'skill' } - // }]`) must stay intact: stripping the type branch collapses the `any` and - // promotes the sibling field constraint into a required top-level `every` - // clause — which would wrongly exclude the other branch's kind (skill cards - // vanish, only markdown skills survive). - const effectiveBaseFilter = - typeFilter && isCardTypeFilter(baseFilter) - ? stripTypeFromFilter(baseFilter) - : baseFilter; + // When typeFilter is present, strip the baseFilter's (parent) type + // constraint as redundant — the picked subtype already implies it. + // `stripTypeFromFilter` only unwraps a top-level `{ type }` node and leaves + // any `any`-alternation intact, so a compound base filter passes through + // whole. + const effectiveBaseFilter = typeFilter + ? stripTypeFromFilter(baseFilter) + : baseFilter; filters = [ ...(effectiveBaseFilter ? [effectiveBaseFilter] : []), ...(typeFilter ? [typeFilter] : []), @@ -273,12 +269,9 @@ export function buildRecentsQuery( : undefined; let filters: Filter[]; if (baseFilter) { - // Only strip a simple single-type base filter (see `buildSearchQuery`); - // a compound base filter must stay intact or the `any` collapses. - const effectiveBaseFilter = - typeFilter && isCardTypeFilter(baseFilter) - ? stripTypeFromFilter(baseFilter) - : baseFilter; + const effectiveBaseFilter = typeFilter + ? stripTypeFromFilter(baseFilter) + : baseFilter; filters = [ ...(effectiveBaseFilter ? [effectiveBaseFilter] : []), ...(typeFilter ? [typeFilter] : []), From 19f4965ca02a22359c3617aded2357d617bb8eac Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 20 Jul 2026 18:20:58 -0400 Subject: [PATCH 06/11] Skill chooser: include the shared Boxel Skills realm in scope Attachable skills live mostly in the shared Boxel Skills realm, which the user-realms-only scope excluded, so they were not selectable. Scope the mixed chooser to the user's own workspaces plus `skillsRealmURL`. Kept bounded (rather than searching every server realm) so the federated search does not stall on unrelated realms' contents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-assistant/skill-menu/index.gts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/host/app/components/ai-assistant/skill-menu/index.gts b/packages/host/app/components/ai-assistant/skill-menu/index.gts index 829f5233e0..d67c7a0ec0 100644 --- a/packages/host/app/components/ai-assistant/skill-menu/index.gts +++ b/packages/host/app/components/ai-assistant/skill-menu/index.gts @@ -17,6 +17,8 @@ import { baseRRI, chooseCard, skillCardRef } from '@cardstack/runtime-common'; import SkillToggle from '@cardstack/host/components/ai-assistant/skill-menu/skill-toggle'; import PillMenu from '@cardstack/host/components/pill-menu'; +import { skillsRealmURL } from '@cardstack/host/lib/utils'; + import type { RoomSkill } from '@cardstack/host/resources/room'; import type RealmServerService from '@cardstack/host/services/realm-server'; @@ -206,12 +208,13 @@ export default class AiAssistantSkillMenu extends Component { ...exclusions, ], }, - // Scope to the user's own workspaces. The mixed chooser (`includeFiles`) - // renders a tile per matching row; without a realm scope it fans out - // across every server realm — including large shared realms — and the - // intended skills may never finish rendering. Skills a user attaches - // come from their workspaces. - realms: this.realmServer.userRealmIdentifiers, + // Scope to the user's own workspaces plus the shared Boxel Skills realm, + // where most attachable skills live. A bounded scope keeps the mixed + // chooser (`includeFiles`) from fanning out across every server realm + // (base, catalog, …) and stalling on their contents. + realms: [ + ...new Set([...this.realmServer.userRealmIdentifiers, skillsRealmURL]), + ], }; let chosen = await chooseCard(query, { includeFiles: true }); if (!chosen) { From cebe262901b0ab6ec9fbf3da147756a4e8fa9c22 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 21 Jul 2026 12:28:39 -0400 Subject: [PATCH 07/11] Matrix skills e2e: warm the Boxel Skills realm before the module's tests The skill chooser now searches the shared Boxel Skills realm as well as the user's workspaces. In the e2e env that realm is indexed from scratch on server startup, so the chooser's federated search stalls until it is ready and the skill tests time out. Add a module-scoped `beforeAll` that waits (polling a known public skill card) until the realm is served from its index before the tests run. Scoped to this module so unrelated matrix suites don't pay the wait. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/matrix/tests/skills.spec.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 80527850d3..032e2954a0 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -22,6 +22,36 @@ test.describe('Skills', () => { let firstUser: { username: string; password: string; credentials: any }; let secondUser: { username: string; password: string; credentials: any }; + // The skill chooser now searches the shared Boxel Skills realm alongside the + // user's own workspaces. In the e2e env that realm is indexed from scratch on + // server startup, so the chooser's federated search stalls until it is ready + // — long enough to blow the per-test timeout. Warm it once for this module by + // waiting until a known skill card is served from the realm's index. Scoped + // here (not the shared harness) so only these tests, which depend on the + // skills realm being searchable, pay the wait. The Skills realm is public- + // read, so no auth is needed. + test.beforeAll(async ({ playwright }) => { + test.setTimeout(180_000); + const skillsRealmURL = `${new URL(appURL).origin}/skills/`; + const probe = `${skillsRealmURL}Skill/boxel-environment`; + const request = await playwright.request.newContext({ + ignoreHTTPSErrors: true, + }); + try { + await expect(async () => { + const res = await request.get(probe, { + headers: { Accept: 'application/vnd.card+json' }, + }); + expect( + res.status(), + `Boxel Skills realm not indexed yet (${probe})`, + ).toBe(200); + }).toPass({ timeout: 170_000, intervals: [2_000, 5_000, 10_000] }); + } finally { + await request.dispose(); + } + }); + test.beforeEach(async () => { firstUser = await createSubscribedUser('user-1'); secondUser = await createSubscribedUser('user-2'); From b153c0ddd37e6c731c37e363162c9fde132a5345 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 21 Jul 2026 14:28:24 -0400 Subject: [PATCH 08/11] Matrix skills e2e: wait for the index queue to drain, not just one card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous warm-up polled a single skill card, which is served before the Boxel Skills realm finishes indexing — so the chooser's federated search still stalled on the mid-index realm and the tests timed out. Poll `/_queue-status` (monitoring-gated; bearer token = sha256('MONITORING' + realm-server seed)) until `pending: 0`, i.e. the whole realm-server index queue has drained, before this module's tests run. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/matrix/tests/skills.spec.ts | 42 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 032e2954a0..2c000288c3 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -16,7 +16,7 @@ import { createRealm, } from '../helpers/index.ts'; import { appURL } from '../support/isolated-realm-server.ts'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; test.describe('Skills', () => { let firstUser: { username: string; password: string; credentials: any }; @@ -24,29 +24,39 @@ test.describe('Skills', () => { // The skill chooser now searches the shared Boxel Skills realm alongside the // user's own workspaces. In the e2e env that realm is indexed from scratch on - // server startup, so the chooser's federated search stalls until it is ready - // — long enough to blow the per-test timeout. Warm it once for this module by - // waiting until a known skill card is served from the realm's index. Scoped - // here (not the shared harness) so only these tests, which depend on the - // skills realm being searchable, pay the wait. The Skills realm is public- - // read, so no auth is needed. + // server startup, so the chooser's federated search stalls until indexing + // finishes — long enough to blow the per-test timeout. Wait once for the + // realm-server index queue to drain (`/_queue-status` → `pending: 0`) before + // this module's tests run. Scoped here (not the shared harness) so only these + // tests, which depend on the skills realm being searchable, pay the wait. test.beforeAll(async ({ playwright }) => { - test.setTimeout(180_000); - const skillsRealmURL = `${new URL(appURL).origin}/skills/`; - const probe = `${skillsRealmURL}Skill/boxel-environment`; + test.setTimeout(300_000); + // `/_queue-status` is monitoring-gated: the bearer token is + // sha256('MONITORING' + realm-server secret seed), matching realm-server's + // `monitoringAuthToken`. The e2e realm server runs with this seed. + const seed = process.env.REALM_SERVER_SECRET_SEED ?? "mum's the word"; + const token = createHash('sha256') + .update('MONITORING') + .update(seed) + .digest('hex'); + const queueStatusURL = `${new URL(appURL).origin}/_queue-status`; const request = await playwright.request.newContext({ ignoreHTTPSErrors: true, }); try { await expect(async () => { - const res = await request.get(probe, { - headers: { Accept: 'application/vnd.card+json' }, + const res = await request.get(queueStatusURL, { + headers: { Authorization: `Bearer ${token}` }, }); + expect(res.status(), 'unexpected /_queue-status response').toBe(200); + const body = (await res.json()) as { + data?: { attributes?: { pending?: number } }; + }; expect( - res.status(), - `Boxel Skills realm not indexed yet (${probe})`, - ).toBe(200); - }).toPass({ timeout: 170_000, intervals: [2_000, 5_000, 10_000] }); + body?.data?.attributes?.pending, + 'realm-server index queue still draining', + ).toBe(0); + }).toPass({ timeout: 290_000, intervals: [2_000, 5_000, 10_000] }); } finally { await request.dispose(); } From 0893378e8b4f82592f2fdfbfa0395134205a3202 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 21 Jul 2026 16:02:04 -0400 Subject: [PATCH 09/11] Skill chooser: include catalog realms in scope; drop matrix warm-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the matrix skills failures (verified from the harness source, not inferred): `support/isolated-realm-server.ts` marks `:4205/test/` and `:4205/skills/` as catalog realms (`show_as_catalog = true`), and the skill fixtures (e.g. `skill-pirate-speak`) live in `:4205/test/`. The scope was `userRealmIdentifiers + skillsRealmURL`, which excludes catalog realms — so the fixtures were never searched and the chooser showed nothing. The federated search itself is fast (200 in ~100-200ms); indexing was never the issue (the harness already gates tests on `_readiness-check`), so the queue-drain warm-up is reverted. Scope the chooser to user workspaces + catalog realms + the Boxel Skills realm. Includes a temporary `[SKILL-SCOPE]` console.warn to confirm the resolved scope in CI; will be removed once green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai-assistant/skill-menu/index.gts | 25 ++++++++--- packages/matrix/tests/skills.spec.ts | 42 +------------------ 2 files changed, 21 insertions(+), 46 deletions(-) diff --git a/packages/host/app/components/ai-assistant/skill-menu/index.gts b/packages/host/app/components/ai-assistant/skill-menu/index.gts index d67c7a0ec0..b5782a11d9 100644 --- a/packages/host/app/components/ai-assistant/skill-menu/index.gts +++ b/packages/host/app/components/ai-assistant/skill-menu/index.gts @@ -208,14 +208,29 @@ export default class AiAssistantSkillMenu extends Component { ...exclusions, ], }, - // Scope to the user's own workspaces plus the shared Boxel Skills realm, - // where most attachable skills live. A bounded scope keeps the mixed - // chooser (`includeFiles`) from fanning out across every server realm - // (base, catalog, …) and stalling on their contents. + // Scope to the realms where attachable skills live: the user's own + // workspaces, any catalog realms, and the shared Boxel Skills realm. realms: [ - ...new Set([...this.realmServer.userRealmIdentifiers, skillsRealmURL]), + ...new Set([ + ...this.realmServer.userRealmIdentifiers, + ...this.realmServer.catalogRealmIdentifiers, + skillsRealmURL, + ]), ], }; + // TEMP DIAGNOSTIC — remove before merge. + console.warn( + '[SKILL-SCOPE] user=', + JSON.stringify(this.realmServer.userRealmIdentifiers), + 'catalog=', + JSON.stringify(this.realmServer.catalogRealmIdentifiers), + 'skills=', + skillsRealmURL, + 'available=', + JSON.stringify(this.realmServer.availableRealmIdentifiers), + 'FINAL=', + JSON.stringify(query.realms), + ); let chosen = await chooseCard(query, { includeFiles: true }); if (!chosen) { return; diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 2c000288c3..80527850d3 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -16,52 +16,12 @@ import { createRealm, } from '../helpers/index.ts'; import { appURL } from '../support/isolated-realm-server.ts'; -import { createHash, randomUUID } from 'crypto'; +import { randomUUID } from 'crypto'; test.describe('Skills', () => { let firstUser: { username: string; password: string; credentials: any }; let secondUser: { username: string; password: string; credentials: any }; - // The skill chooser now searches the shared Boxel Skills realm alongside the - // user's own workspaces. In the e2e env that realm is indexed from scratch on - // server startup, so the chooser's federated search stalls until indexing - // finishes — long enough to blow the per-test timeout. Wait once for the - // realm-server index queue to drain (`/_queue-status` → `pending: 0`) before - // this module's tests run. Scoped here (not the shared harness) so only these - // tests, which depend on the skills realm being searchable, pay the wait. - test.beforeAll(async ({ playwright }) => { - test.setTimeout(300_000); - // `/_queue-status` is monitoring-gated: the bearer token is - // sha256('MONITORING' + realm-server secret seed), matching realm-server's - // `monitoringAuthToken`. The e2e realm server runs with this seed. - const seed = process.env.REALM_SERVER_SECRET_SEED ?? "mum's the word"; - const token = createHash('sha256') - .update('MONITORING') - .update(seed) - .digest('hex'); - const queueStatusURL = `${new URL(appURL).origin}/_queue-status`; - const request = await playwright.request.newContext({ - ignoreHTTPSErrors: true, - }); - try { - await expect(async () => { - const res = await request.get(queueStatusURL, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status(), 'unexpected /_queue-status response').toBe(200); - const body = (await res.json()) as { - data?: { attributes?: { pending?: number } }; - }; - expect( - body?.data?.attributes?.pending, - 'realm-server index queue still draining', - ).toBe(0); - }).toPass({ timeout: 290_000, intervals: [2_000, 5_000, 10_000] }); - } finally { - await request.dispose(); - } - }); - test.beforeEach(async () => { firstUser = await createSubscribedUser('user-1'); secondUser = await createSubscribedUser('user-2'); From 064e678a29fbb6372655765ce2c68947ad900b38 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 21 Jul 2026 16:44:12 -0400 Subject: [PATCH 10/11] Matrix skills e2e: narrow the chooser search to the target skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed from the failing run's Playwright trace: the catalog-scope fix works (the chooser shows "79 results across 2 realms" — Boxel Skills + Test Workspace A), but the shared Boxel Skills realm contributes 76 skills that render first, pushing the target test-realm skill below the fold — so its tile exists but is not visible and the click times out. Have `attachSkill` type the target's URL into the chooser search to narrow to it before clicking (the same pattern messages.spec.ts already uses). Also drop the temporary scope-diagnostic console.warn. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-assistant/skill-menu/index.gts | 13 ------------- packages/matrix/tests/skills.spec.ts | 4 ++++ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/host/app/components/ai-assistant/skill-menu/index.gts b/packages/host/app/components/ai-assistant/skill-menu/index.gts index b5782a11d9..46965f1010 100644 --- a/packages/host/app/components/ai-assistant/skill-menu/index.gts +++ b/packages/host/app/components/ai-assistant/skill-menu/index.gts @@ -218,19 +218,6 @@ export default class AiAssistantSkillMenu extends Component { ]), ], }; - // TEMP DIAGNOSTIC — remove before merge. - console.warn( - '[SKILL-SCOPE] user=', - JSON.stringify(this.realmServer.userRealmIdentifiers), - 'catalog=', - JSON.stringify(this.realmServer.catalogRealmIdentifiers), - 'skills=', - skillsRealmURL, - 'available=', - JSON.stringify(this.realmServer.availableRealmIdentifiers), - 'FINAL=', - JSON.stringify(query.realms), - ); let chosen = await chooseCard(query, { includeFiles: true }); if (!chosen) { return; diff --git a/packages/matrix/tests/skills.spec.ts b/packages/matrix/tests/skills.spec.ts index 80527850d3..34f8ca672e 100644 --- a/packages/matrix/tests/skills.spec.ts +++ b/packages/matrix/tests/skills.spec.ts @@ -39,6 +39,10 @@ test.describe('Skills', () => { .click(); } await page.locator('[data-test-pill-menu-add-button]').click(); + // The chooser spans the shared Boxel Skills realm (dozens of skills), so the + // target tile can render below the fold. Narrow to it by URL first so it's + // the visible result before clicking. + await page.locator('[data-test-search-field]').fill(cardId); await page.locator(`[data-test-item-button="${cardId}"]`).click(); await page.locator('[data-test-card-chooser-go-button]').click(); From c5f03f00e4478971b691247ec7bfa538cf471b37 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 21 Jul 2026 18:03:30 -0400 Subject: [PATCH 11/11] Card chooser: don't auto-focus (collapse others) for a multi-realm scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the blank/unclickable skill tiles (confirmed live via DevTools): the chooser set `initialFocusedSection` to the first selected realm, and `isCollapsed` collapses every *other* realm's section to just its header (`display:none` on its grid). Once the skill chooser scoped to multiple realms (user + catalog + skills), the target skill's realm — not the first — was collapsed, so its tile rendered at height 0 and the click timed out. (The old chooser passed no realm scope, so nothing was focused or collapsed.) Only auto-focus when the scope is a single realm (where collapsing is a no-op); for a multi-realm scope, focus nothing so every section's results stay visible. Verified in the running app: the target tile now renders at 252px, clickable. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/components/card-search/panel.gts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index 5acd63506a..6941f915da 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -67,7 +67,11 @@ export default class SearchPanel extends Component { private get initialFocusedSectionId(): string | null { let realmURLs = this.args.initialSelectedRealms; - if (!realmURLs || realmURLs.length === 0) { + // Only auto-focus a section when the search is pinned to a single realm. + // Focusing collapses every other realm's section to its header, so for a + // multi-realm scope (e.g. the skill chooser's user + catalog + skills) + // focusing the first realm would hide the results of all the others. + if (!realmURLs || realmURLs.length !== 1) { return null; } return `realm:${realmURLs[0].href}`;