From 5daf6628713852919ee4b8d4984ce719f7f6b404 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 17 Jul 2026 17:02:27 +0800 Subject: [PATCH 1/3] Create-listing modal: supporting cards row Adds supportingCardIds to ListingCreateInput and a second picker row to the create-listing modal for cards that install with the listing but are not shown on the listing detail page (e.g. data cards a query-based example depends on). The examples picker stays filtered to the listed type; the supporting picker accepts any card. Co-Authored-By: Claude Fable 5 --- packages/base/Skill/catalog-listing.json | 2 +- packages/base/command.gts | 2 + .../operator-mode/create-listing-modal.gts | 190 ++++++++++++++---- .../components/create-listing-modal-test.gts | 53 +++++ 4 files changed, 206 insertions(+), 41 deletions(-) diff --git a/packages/base/Skill/catalog-listing.json b/packages/base/Skill/catalog-listing.json index 8c7731fe4d5..680c7f4af0b 100644 --- a/packages/base/Skill/catalog-listing.json +++ b/packages/base/Skill/catalog-listing.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "instructions": "Before running the operation, ensure the following conditions are met:\n\n1. The create, use, install, and remix commands must exist and be callable.\n2. Except create, the user must provide all of the following:\n- A valid Realm URL\n- A Listing Card\n- An Action Type: \"use\", \"install\", or \"remix\"\n\n3. To create, user must attach at least one card instance to create the listing.\n\nIf any value is missing, prompt the user to provide the missing input(s). If all inputs are available, proceed automatically without asking for confirmation.\n\nBased on the action type:\n- If actionType === \"create\" → run the create command\n- If actionType === \"use\" → run the use command\n- If actionType === \"install\" → run the install command\n- If actionType === \"remix\" → run the remix command\n\nIf actionType === \"create\" → run create with following payload(s):\n - Open card id: [attached instance URL]\n\n Else, use the following inputs to run the command:\n- Realm URL: [user input]\n- Listing: [user input]\n\nIf actionType is remix:\n- After running remix, also run remix code to generate two example prompts.\n- Respond with:\n 1. A confirmation message summarizing the remix operation.\n 2. A follow-up message with two listing-related remix prompts.If specific prompts can't be generated, provide two general suggestions (e.g., \"Change to dark theme\", \"Convert to minimalist layout).", + "instructions": "Before running the operation, ensure the following conditions are met:\n\n1. The create, use, install, and remix commands must exist and be callable.\n2. Except create, the user must provide all of the following:\n- A valid Realm URL\n- A Listing Card\n- An Action Type: \"use\", \"install\", or \"remix\"\n\n3. To create, user must attach at least one card instance to create the listing.\n\nIf any value is missing, prompt the user to provide the missing input(s). If all inputs are available, proceed automatically without asking for confirmation.\n\nBased on the action type:\n- If actionType === \"create\" → run the create command\n- If actionType === \"use\" → run the use command\n- If actionType === \"install\" → run the install command\n- If actionType === \"remix\" → run the remix command\n\nIf actionType === \"create\" → run create with following payload(s):\n - Open card id: [attached instance URL]\n - Supporting card ids (optional): attached cards that should install with the listing but not appear on the listing detail page (e.g. data cards a query-based example depends on). Attached cards that are not instances of the listed type are treated as supporting cards automatically.\n\n Else, use the following inputs to run the command:\n- Realm URL: [user input]\n- Listing: [user input]\n\nIf actionType is remix:\n- After running remix, also run remix code to generate two example prompts.\n- Respond with:\n 1. A confirmation message summarizing the remix operation.\n 2. A follow-up message with two listing-related remix prompts.If specific prompts can't be generated, provide two general suggestions (e.g., \"Change to dark theme\", \"Convert to minimalist layout).", "commands": [ { "codeRef": { diff --git a/packages/base/command.gts b/packages/base/command.gts index da0fcc8505c..669c77597dc 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -480,6 +480,8 @@ export class RetrySubmissionWorkflowInput extends CardDef { export class ListingCreateInput extends CardDef { @field openCardIds = containsMany(StringField); + // installed with the listing but not shown in the listing detail view + @field supportingCardIds = containsMany(StringField); @field codeRef = contains(CodeRefField); @field targetRealm = contains(RealmField); } diff --git a/packages/host/app/components/operator-mode/create-listing-modal.gts b/packages/host/app/components/operator-mode/create-listing-modal.gts index 18e18a245af..7ce1e8aa7df 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -19,6 +19,7 @@ import { import { IconX, IconPlus } from '@cardstack/boxel-ui/icons'; import { + baseCardRef, chooseCard, isResolvedCodeRef, rri, @@ -49,6 +50,7 @@ export default class CreateListingModal extends Component { @service declare private realm: RealmService; @tracked private _selectedExampleURLs: string[] | null = null; + @tracked private _selectedSupportingCardURLs: string[] | null = null; private get payload() { return this.operatorModeStateService.createListingModalPayload; @@ -91,14 +93,26 @@ export default class CreateListingModal extends Component { return (this.payload?.declarationKind ?? 'card') === 'card'; } - private get selectedExampleCardUrls(): string[] { - return this.selectedExampleURLs.map((url) => - url.endsWith('.json') ? url : `${url}.json`, - ); + private get selectedSupportingCardURLs(): string[] { + return this._selectedSupportingCardURLs ?? []; + } + + private get hasSelectedSupportingCards(): boolean { + return this.selectedSupportingCardURLs.length > 0; } - private get selectedExampleRealms(): string[] { - let realms = this.selectedExampleURLs.flatMap((cardUrl) => { + // The `entry` query for a row's selected cards: scoped to the chosen card + // URLs (matched by index file URL, hence the `.json` suffixing) and their + // realms, with the `atom` rendering bound through the filter's `htmlQuery` + // (the way to select a prerendered format). The engine lifts the htmlQuery + // binding out of the eq (which then dissolves). `scope: 'cards'` pins the + // instance row, dropping each card's dual-indexed `.json` file row that + // shares its `cardUrls` URL. + private atomSearchQuery(cardURLs: string[]): SearchEntryWireQuery { + let cardUrls = cardURLs.map((url) => + url.endsWith('.json') ? url : `${url}.json`, + ); + let realms = cardURLs.flatMap((cardUrl) => { try { let realmURL = this.realm.realmOf(rri(cardUrl)); return realmURL ? [realmURL] : []; @@ -106,20 +120,9 @@ export default class CreateListingModal extends Component { return []; } }); - return [...new Set(realms)]; - } - - // The `entry` query for the selected example cards: scoped to the - // chosen card URLs (matched by index file URL, hence the `.json`-suffixed - // `selectedExampleCardUrls`) and their realms, with the `atom` rendering - // bound through the filter's `htmlQuery` (the way to select a prerendered - // format). The engine lifts the htmlQuery binding out of the eq (which then - // dissolves). `scope: 'cards'` pins the instance row, dropping each example's - // dual-indexed `.json` file row that shares its `cardUrls` URL. - private get exampleSearchQuery(): SearchEntryWireQuery { return { - cardUrls: this.selectedExampleCardUrls, - realms: this.selectedExampleRealms, + cardUrls, + realms: [...new Set(realms)], scope: 'cards', filter: { eq: { @@ -129,6 +132,14 @@ export default class CreateListingModal extends Component { }; } + private get exampleSearchQuery(): SearchEntryWireQuery { + return this.atomSearchQuery(this.selectedExampleURLs); + } + + private get supportingCardSearchQuery(): SearchEntryWireQuery { + return this.atomSearchQuery(this.selectedSupportingCardURLs); + } + private chooseExamples = task(async () => { let codeRef = this.codeRef; if (!codeRef) { @@ -158,6 +169,31 @@ export default class CreateListingModal extends Component { ); } + private chooseSupportingCards = task(async () => { + let consumingRealm = this.payload?.targetRealm + ? new URL(this.payload.targetRealm) + : undefined; + let selected = await chooseCard( + { filter: { type: baseCardRef } }, + { + multiSelect: true, + consumingRealm, + preselectConsumingRealm: true, + preselectedCardUrls: this.selectedSupportingCardURLs, + }, + ); + if (selected) { + this._selectedSupportingCardURLs = selected; + } + }); + + @action private removeSelectedSupportingCard(urlToRemove: string) { + let normalizedUrlToRemove = removeCardJsonExtension(urlToRemove); + this._selectedSupportingCardURLs = this.selectedSupportingCardURLs.filter( + (url) => removeCardJsonExtension(url) !== normalizedUrlToRemove, + ); + } + private createListing = task(async () => { let payload = this.payload; if (!payload) { @@ -171,6 +207,7 @@ export default class CreateListingModal extends Component { let targetRealm = payload.targetRealm; let openCardIds = this.selectedExampleURLs; + let supportingCardIds = this.selectedSupportingCardURLs; // Load listing-create from the catalog realm at runtime so we pick up // whatever version is shipped in the realm content, not a stale @@ -191,6 +228,7 @@ export default class CreateListingModal extends Component { codeRef: ResolvedCodeRef; targetRealm: string; openCardIds: string[]; + supportingCardIds: string[]; }) => Promise<{ listing?: { id?: string }; backgroundWork?: Promise; @@ -205,6 +243,7 @@ export default class CreateListingModal extends Component { codeRef, targetRealm, openCardIds, + supportingCardIds, }); // Navigate to the listing in code mode with isolated preview @@ -228,11 +267,13 @@ export default class CreateListingModal extends Component { } this._selectedExampleURLs = null; + this._selectedSupportingCardURLs = null; this.operatorModeStateService.dismissCreateListingModal(); }); @action private onClose() { this._selectedExampleURLs = null; + this._selectedSupportingCardURLs = null; this.operatorModeStateService.dismissCreateListingModal(); } @@ -280,29 +321,26 @@ export default class CreateListingModal extends Component { data-test-create-listing-examples > {{#if this.hasSelectedExamples}} -
+
{{#if results.isLoading}} -
+
{{else}} {{#each results.entries key='id' as |entry|}}
{
{{else}} - No examples selected + No examples selected {{/if}}
+ + +
+

+ Installed with the listing but not shown on the listing page. +

+ {{#if this.hasSelectedSupportingCards}} +
+ + {{#if results.isLoading}} +
+ +
+ {{else}} + {{#each results.entries key='id' as |entry|}} +
+ + +
+ {{/each}} + {{/if}} +
+
+ {{else}} + No supporting cards selected + {{/if}} + +
+
{{/if}} <:footer> @@ -395,12 +500,17 @@ export default class CreateListingModal extends Component { flex-direction: column; gap: var(--boxel-sp-xs); } - .add-examples-button { + .field-hint { + font: var(--boxel-font-xs); + color: var(--boxel-450); + margin: 0; + } + .add-cards-button { --boxel-button-padding: var(--boxel-sp-xs) var(--boxel-sp-sm); align-self: flex-start; gap: var(--boxel-sp-xxxs); } - .no-examples-message { + .no-cards-message { font: var(--boxel-font-sm); color: var(--boxel-500); display: flex; @@ -412,7 +522,7 @@ export default class CreateListingModal extends Component { min-height: 3rem; width: 100%; } - .selected-examples-list { + .selected-cards-list { display: flex; flex-wrap: wrap; gap: var(--boxel-sp-xs); @@ -423,28 +533,28 @@ export default class CreateListingModal extends Component { width: 100%; align-items: start; } - .selected-example-atom { + .selected-card-atom { position: relative; min-width: 0; display: inline-flex; align-items: center; } - .selected-example-atom + .selected-card-atom :deep(.field-component-card.atom-format.display-container-true) { min-width: 0; } - .selected-example-atom + .selected-card-atom :deep(.field-component-card.atom-format.display-container-true) { padding-right: calc( var(--boxel-sp-xs) + var(--boxel-icon-sm) + var(--boxel-sp-6xs) ); } - .selected-example-atom :deep(.card) { + .selected-card-atom :deep(.card) { border: none; box-shadow: none; background: transparent; } - .selected-example-remove-button { + .selected-card-remove-button { --icon-color: var(--boxel-700); --icon-bg: transparent; --icon-border: transparent; @@ -457,14 +567,14 @@ export default class CreateListingModal extends Component { border-radius: 999px; opacity: 0.72; } - .selected-example-remove-button:hover, - .selected-example-remove-button:focus-visible { + .selected-card-remove-button:hover, + .selected-card-remove-button:focus-visible { --icon-bg: var(--boxel-200); --icon-border: var(--boxel-200); --icon-color: var(--boxel-900); opacity: 1; } - .selected-example-loading { + .selected-card-loading { display: inline-flex; align-items: center; justify-content: center; @@ -473,7 +583,7 @@ export default class CreateListingModal extends Component { width: 100%; } :deep(.create-listing) { - height: 30rem; + height: 34rem; } .footer-loading-message { font: var(--boxel-font-sm); diff --git a/packages/host/tests/integration/components/create-listing-modal-test.gts b/packages/host/tests/integration/components/create-listing-modal-test.gts index b0bb1e2ad85..a3d64d19093 100644 --- a/packages/host/tests/integration/components/create-listing-modal-test.gts +++ b/packages/host/tests/integration/components/create-listing-modal-test.gts @@ -193,4 +193,57 @@ module('Integration | components | create-listing-modal', function (hooks) { assert.dom('[data-test-create-listing-examples]').doesNotExist(); assert.dom('[data-test-choose-examples-button]').doesNotExist(); }); + + test('shows supporting cards row for a card type', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreateListingModal({ + codeRef: { + module: testRRI('pet'), + name: 'Pet', + }, + targetRealm: testRealmURL, + declarationKind: 'card', + }); + + await waitFor('[data-test-create-listing-modal]'); + await waitFor('[data-test-create-listing-supporting-cards]'); + + assert.dom('[data-test-create-listing-supporting-cards]').exists(); + assert + .dom('[data-test-create-listing-supporting-cards]') + .includesText('not shown on the listing page'); + assert + .dom('[data-test-selected-supporting-cards]') + .doesNotExist('no supporting cards are preselected'); + assert + .dom('[data-test-choose-supporting-cards-button]') + .hasText('Add Supporting Cards'); + }); + + test('hides supporting cards for field listings', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreateListingModal({ + codeRef: { + module: testRRI('pet'), + name: 'PetName', + }, + targetRealm: testRealmURL, + declarationKind: 'field', + }); + + await waitFor('[data-test-create-listing-modal]'); + + assert.dom('[data-test-create-listing-supporting-cards]').doesNotExist(); + assert.dom('[data-test-choose-supporting-cards-button]').doesNotExist(); + }); }); From 38fa58fbad846ac384d99c9106c0d7bb54d5fa40 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 17 Jul 2026 18:38:14 +0800 Subject: [PATCH 2/3] Forward supportingCardIds through the create-listing modal payload Tool-driven create flows can pass supportingCardIds; previously the open-create-listing-modal tool dropped them, so the modal submitted an empty list unless the user reselected the cards manually. Co-Authored-By: Claude Fable 5 --- .../operator-mode/create-listing-modal.gts | 4 ++- .../services/operator-mode-state-service.ts | 1 + .../app/tools/open-create-listing-modal.ts | 3 ++ .../components/create-listing-modal-test.gts | 33 +++++++++++++++++++ .../tools/open-create-listing-modal-test.gts | 24 ++++++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/host/app/components/operator-mode/create-listing-modal.gts b/packages/host/app/components/operator-mode/create-listing-modal.gts index 7ce1e8aa7df..1e1db658467 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -94,7 +94,9 @@ export default class CreateListingModal extends Component { } private get selectedSupportingCardURLs(): string[] { - return this._selectedSupportingCardURLs ?? []; + return ( + this._selectedSupportingCardURLs ?? this.payload?.supportingCardIds ?? [] + ); } private get hasSelectedSupportingCards(): boolean { diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index a65bb98f056..2fead909587 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -77,6 +77,7 @@ export interface CreateListingModalPayload { codeRef: CodeRef; targetRealm: string; openCardIds?: RealmResourceIdentifier[]; + supportingCardIds?: RealmResourceIdentifier[]; declarationKind: 'card' | 'field'; } diff --git a/packages/host/app/tools/open-create-listing-modal.ts b/packages/host/app/tools/open-create-listing-modal.ts index d171d5c894e..c8634b79f1c 100644 --- a/packages/host/app/tools/open-create-listing-modal.ts +++ b/packages/host/app/tools/open-create-listing-modal.ts @@ -45,6 +45,9 @@ export default class OpenCreateListingModalTool extends HostBaseTool< codeRef: input.codeRef, targetRealm: input.targetRealm, openCardIds: input.openCardIds as RealmResourceIdentifier[] | undefined, + supportingCardIds: input.supportingCardIds as + | RealmResourceIdentifier[] + | undefined, declarationKind, }); } diff --git a/packages/host/tests/integration/components/create-listing-modal-test.gts b/packages/host/tests/integration/components/create-listing-modal-test.gts index a3d64d19093..35f643ad019 100644 --- a/packages/host/tests/integration/components/create-listing-modal-test.gts +++ b/packages/host/tests/integration/components/create-listing-modal-test.gts @@ -225,6 +225,39 @@ module('Integration | components | create-listing-modal', function (hooks) { .hasText('Add Supporting Cards'); }); + test('shows seeded supporting card atom from payload', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreateListingModal({ + codeRef: { + module: testRRI('pet'), + name: 'Pet', + }, + targetRealm: testRealmURL, + openCardIds: [rri(`${testRealmURL}Pet/mango`)], + supportingCardIds: [rri(`${testRealmURL}Pet/jackie`)], + declarationKind: 'card', + }); + + await waitFor('[data-test-create-listing-modal]'); + await waitFor( + `[data-test-selected-supporting-card="${testRealmURL}Pet/jackie"]`, + ); + + assert + .dom(`[data-test-selected-supporting-card="${testRealmURL}Pet/jackie"]`) + .exists(); + assert + .dom( + `[data-test-selected-supporting-card-remove="${testRealmURL}Pet/jackie"]`, + ) + .exists(); + }); + test('hides supporting cards for field listings', async function (assert) { await renderComponent( class TestDriver extends GlimmerComponent { diff --git a/packages/host/tests/integration/tools/open-create-listing-modal-test.gts b/packages/host/tests/integration/tools/open-create-listing-modal-test.gts index e4d24bd336f..2d6e3d56ae5 100644 --- a/packages/host/tests/integration/tools/open-create-listing-modal-test.gts +++ b/packages/host/tests/integration/tools/open-create-listing-modal-test.gts @@ -86,10 +86,34 @@ module('Integration | tools | open-create-listing-modal', function (hooks) { }, targetRealm: testRealmURL, openCardIds: [rri(`${testRealmURL}Pet/mango`)], + supportingCardIds: [], declarationKind: 'card', }); }); + test('stores modal payload with supportingCardIds', async function (assert) { + let toolService = getService('tool-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + + let command = new OpenCreateListingModalTool(toolService.toolContext); + + await command.execute({ + codeRef: { + module: `${testRealmURL}pet`, + name: 'Pet', + }, + targetRealm: testRealmURL, + openCardIds: [rri(`${testRealmURL}Pet/mango`)], + supportingCardIds: [rri(`${testRealmURL}Pet/jackie`)], + } as never); + + assert.deepEqual( + operatorModeStateService.createListingModalPayload?.supportingCardIds, + [rri(`${testRealmURL}Pet/jackie`)], + 'supportingCardIds pass through to the modal payload', + ); + }); + test('stores modal payload without openCardIds', async function (assert) { let toolService = getService('tool-service'); let operatorModeStateService = getService('operator-mode-state-service'); From 5cecbd02e146e4abf64b3d70308b74e168c5f216 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 17 Jul 2026 18:52:18 +0800 Subject: [PATCH 3/3] Fix typos in catalog-listing skill instructions Co-Authored-By: Claude Fable 5 --- packages/base/Skill/catalog-listing.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/Skill/catalog-listing.json b/packages/base/Skill/catalog-listing.json index 680c7f4af0b..b3d6d81c0f4 100644 --- a/packages/base/Skill/catalog-listing.json +++ b/packages/base/Skill/catalog-listing.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "instructions": "Before running the operation, ensure the following conditions are met:\n\n1. The create, use, install, and remix commands must exist and be callable.\n2. Except create, the user must provide all of the following:\n- A valid Realm URL\n- A Listing Card\n- An Action Type: \"use\", \"install\", or \"remix\"\n\n3. To create, user must attach at least one card instance to create the listing.\n\nIf any value is missing, prompt the user to provide the missing input(s). If all inputs are available, proceed automatically without asking for confirmation.\n\nBased on the action type:\n- If actionType === \"create\" → run the create command\n- If actionType === \"use\" → run the use command\n- If actionType === \"install\" → run the install command\n- If actionType === \"remix\" → run the remix command\n\nIf actionType === \"create\" → run create with following payload(s):\n - Open card id: [attached instance URL]\n - Supporting card ids (optional): attached cards that should install with the listing but not appear on the listing detail page (e.g. data cards a query-based example depends on). Attached cards that are not instances of the listed type are treated as supporting cards automatically.\n\n Else, use the following inputs to run the command:\n- Realm URL: [user input]\n- Listing: [user input]\n\nIf actionType is remix:\n- After running remix, also run remix code to generate two example prompts.\n- Respond with:\n 1. A confirmation message summarizing the remix operation.\n 2. A follow-up message with two listing-related remix prompts.If specific prompts can't be generated, provide two general suggestions (e.g., \"Change to dark theme\", \"Convert to minimalist layout).", + "instructions": "Before running the operation, ensure the following conditions are met:\n\n1. The create, use, install, and remix commands must exist and be callable.\n2. Except create, the user must provide all of the following:\n- A valid Realm URL\n- A Listing Card\n- An Action Type: \"use\", \"install\", or \"remix\"\n\n3. To create, user must attach at least one card instance to create the listing.\n\nIf any value is missing, prompt the user to provide the missing input(s). If all inputs are available, proceed automatically without asking for confirmation.\n\nBased on the action type:\n- If actionType === \"create\" → run the create command\n- If actionType === \"use\" → run the use command\n- If actionType === \"install\" → run the install command\n- If actionType === \"remix\" → run the remix command\n\nIf actionType === \"create\" → run create with following payload(s):\n - Open card id: [attached instance URL]\n - Supporting card ids (optional): attached cards that should install with the listing but not appear on the listing detail page (e.g. data cards a query-based example depends on). Attached cards that are not instances of the listed type are treated as supporting cards automatically.\n\n Else, use the following inputs to run the command:\n- Realm URL: [user input]\n- Listing: [user input]\n\nIf actionType is remix:\n- After running remix, also run remix code to generate two example prompts.\n- Respond with:\n 1. A confirmation message summarizing the remix operation.\n 2. A follow-up message with two listing-related remix prompts. If specific prompts can't be generated, provide two general suggestions (e.g., \"Change to dark theme\", \"Convert to minimalist layout\").", "commands": [ { "codeRef": {