From 8737f7bbea64e8cf74544c3b972183c3cac6a394 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sat, 14 Mar 2026 23:24:29 +0800 Subject: [PATCH 01/34] remove navigate the listing open in interact mode --- packages/host/app/commands/listing-create.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index ce94c134ece..54e6d940cf7 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -149,7 +149,6 @@ export default class ListingCreateCommand extends HostBaseCommand< if (!listingId) { throw new Error('Failed to create listing card (no localId)'); } - await this.operatorModeStateService.openCardInInteractMode(listingId); const commandModule = await this.loadCommandModule(); const listingCard = listing as CardAPI.CardDef; // ensure correct type From d700f68a667cd5787bf800f32e760626a1df54cc Mon Sep 17 00:00:00 2001 From: Lucas Date: Sun, 15 Mar 2026 10:26:26 +0800 Subject: [PATCH 02/34] implement openCreateListingModal as a host command --- packages/host/app/commands/index.ts | 6 + .../app/commands/open-create-listing-modal.ts | 33 ++ .../components/operator-mode/container.gts | 2 + .../operator-mode/create-listing-modal.gts | 348 ++++++++++++++++++ .../services/operator-mode-state-service.ts | 16 + 5 files changed, 405 insertions(+) create mode 100644 packages/host/app/commands/open-create-listing-modal.ts create mode 100644 packages/host/app/components/operator-mode/create-listing-modal.gts diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index 4fd424e63da..d125fdf950d 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -37,6 +37,7 @@ import * as ListingUpdateSpecsCommandModule from './listing-update-specs'; import * as ListingUseCommandModule from './listing-use'; import * as OneShotLlmRequestCommandModule from './one-shot-llm-request'; import * as OpenAiAssistantRoomCommandModule from './open-ai-assistant-room'; +import * as OpenCreateListingModalCommandModule from './open-create-listing-modal'; import * as OpenInInteractModeModule from './open-in-interact-mode'; import * as OpenWorkspaceCommandModule from './open-workspace'; import * as PatchCardInstanceCommandModule from './patch-card-instance'; @@ -256,6 +257,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/open-ai-assistant-room', OpenAiAssistantRoomCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/open-create-listing-modal', + OpenCreateListingModalCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/open-workspace', OpenWorkspaceCommandModule, @@ -404,6 +409,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ ListingUseCommandModule.default, OneShotLlmRequestCommandModule.default, OpenAiAssistantRoomCommandModule.default, + OpenCreateListingModalCommandModule.default, OpenInInteractModeModule.default, OpenWorkspaceCommandModule.default, GenerateThemeExampleCommandModule.default, diff --git a/packages/host/app/commands/open-create-listing-modal.ts b/packages/host/app/commands/open-create-listing-modal.ts new file mode 100644 index 00000000000..c7e51d1a285 --- /dev/null +++ b/packages/host/app/commands/open-create-listing-modal.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class OpenCreateListingModalCommand extends HostBaseCommand< + typeof BaseCommandModule.ListingCreateInput +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Open create listing confirmation modal'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ListingCreateInput } = commandModule; + return ListingCreateInput; + } + + requireInputFields = ['codeRef', 'targetRealm']; + + protected async run( + input: BaseCommandModule.ListingCreateInput, + ): Promise { + this.operatorModeStateService.openCreateListingModal({ + codeRef: input.codeRef, + targetRealm: input.targetRealm, + openCardId: input.openCardId, + }); + } +} diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index 45653d4104c..b514090ff56 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -41,6 +41,7 @@ import PrerenderedCardSearch from '../prerendered-card-search'; import { Submodes } from '../submode-switcher'; import ChooseFileModal from './choose-file-modal'; +import CreateListingModal from './create-listing-modal'; import type CardService from '../../services/card-service'; import type CommandService from '../../services/command-service'; @@ -143,6 +144,7 @@ export default class OperatorModeContainer extends Component { diff --git a/packages/host/app/components/operator-mode/submode-layout.gts b/packages/host/app/components/operator-mode/submode-layout.gts index 5817ddeecc3..f801d63b65c 100644 --- a/packages/host/app/components/operator-mode/submode-layout.gts +++ b/packages/host/app/components/operator-mode/submode-layout.gts @@ -31,7 +31,6 @@ import AiAssistantPanel from '@cardstack/host/components/ai-assistant/panel'; import AiAssistantToast from '@cardstack/host/components/ai-assistant/toast'; import ProfileSettingsModal from '@cardstack/host/components/operator-mode/profile/profile-settings-modal'; import ProfileInfoPopover from '@cardstack/host/components/operator-mode/profile-info-popover'; -import Toast from '@cardstack/host/components/toast'; import config from '@cardstack/host/config/environment'; @@ -455,18 +454,6 @@ export default class SubmodeLayout extends Component { @onInputInsertion={{this.storeSearchElement}} /> {{/if}} - {{#if this.operatorModeStateService.toast}} - - {{this.operatorModeStateService.toast.message}} - - {{/if}} void; + openCardIds?: string[]; } // Below types form a raw POJO representation of operator mode state. @@ -155,8 +148,7 @@ export default class OperatorModeStateService extends Service { private moduleInspectorHistory: Record; @tracked profileSettingsOpen = false; - @tracked createListingModalRequest?: CreateListingModalRequest; - @tracked toast?: OperatorModeToast; + @tracked createListingModalPayload?: CreateListingModalPayload; @service declare private cardService: CardService; @service declare private codeSemanticsService: CodeSemanticsService; @@ -225,20 +217,12 @@ export default class OperatorModeStateService extends Service { this.schedulePersist(); }; - showCreateListingModal = (request: CreateListingModalRequest) => { - this.createListingModalRequest = request; + showCreateListingModal = (payload: CreateListingModalPayload) => { + this.createListingModalPayload = payload; }; dismissCreateListingModal = () => { - this.createListingModalRequest = undefined; - }; - - showToast = (toast: OperatorModeToast) => { - this.toast = toast; - }; - - dismissToast = () => { - this.toast = undefined; + this.createListingModalPayload = undefined; }; setNewFileDropdownOpen = () => { @@ -271,8 +255,7 @@ export default class OperatorModeStateService extends Service { this.cardTitles = new TrackedMap(); this.moduleInspectorHistory = {}; this.profileSettingsOpen = false; - this.createListingModalRequest = undefined; - this.toast = undefined; + this.createListingModalPayload = undefined; window.localStorage.removeItem(ModuleInspectorSelections); this.schedulePersist(); } From 74d2a99a09304ec3ffa9f53db65338221be7438b Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 15:33:21 +0800 Subject: [PATCH 11/34] add isListingDef checking --- .../catalog-app/listing/listing.gts | 1 + .../operator-mode/create-listing-modal.gts | 4 +-- .../components/operator-mode/detail-panel.gts | 29 ++++++++++++++----- packages/runtime-common/code-ref.ts | 8 +++++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index 90d0b733264..05d7369cea3 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -564,6 +564,7 @@ class EmbeddedTemplate extends Component { export class Listing extends CardDef { static displayName = 'Listing'; static headerColor = '#6638ff'; + static isListingDef = true; @field name = contains(StringField); @field summary = contains(MarkdownField); 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 32dc0896efe..bf818544881 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -22,11 +22,11 @@ import { type ResolvedCodeRef, } from '@cardstack/runtime-common'; -import ItemButton from '@cardstack/host/components/card-search/item-button'; -import { Submodes } from '@cardstack/host/components/submode-switcher'; import ListingCreateCommand from '@cardstack/host/commands/listing-create'; +import ItemButton from '@cardstack/host/components/card-search/item-button'; import ModalContainer from '@cardstack/host/components/modal-container'; import { SelectedTypePill } from '@cardstack/host/components/operator-mode/create-file-modal'; +import { Submodes } from '@cardstack/host/components/submode-switcher'; import { getSearch } from '@cardstack/host/resources/search'; import type CommandService from '@cardstack/host/services/command-service'; diff --git a/packages/host/app/components/operator-mode/detail-panel.gts b/packages/host/app/components/operator-mode/detail-panel.gts index d6c1421472f..3155c214974 100644 --- a/packages/host/app/components/operator-mode/detail-panel.gts +++ b/packages/host/app/components/operator-mode/detail-panel.gts @@ -34,6 +34,8 @@ import { isFileDef, isBaseDef, isCardErrorJSONAPI, + isListingDef, + isListingInstance, internalKeyFor, type ResolvedCodeRef, type CardErrorJSONAPI, @@ -255,12 +257,13 @@ export default class DetailPanel extends Component { ] : []), ...(this.realm.canWrite(this.args.readyFile.url) && - this.args.selectedDeclaration?.exportName + this.args.selectedDeclaration?.exportName && + !isListingDef(this.args.selectedDeclaration?.cardOrField) ? [ { label: 'Create Listing', icon: Package, - handler: this.createListingWithAI, + handler: this.createListing, }, ] : []), @@ -298,13 +301,18 @@ export default class DetailPanel extends Component { icon: Copy, handler: this.duplicateInstance, }, - ...(this.realm.canWrite(this.args.readyFile.url) + ...(this.realm.canWrite(this.args.readyFile.url) && + !isListingInstance(this.args.cardInstance) ? [ { label: 'Create Listing', icon: Package, - handler: this.createListingWithAI, + handler: this.createListing, }, + ] + : []), + ...(this.realm.canWrite(this.args.readyFile.url) + ? [ { label: 'Delete', icon: IconTrash, @@ -420,7 +428,7 @@ export default class DetailPanel extends Component { this.args.openSearch(`carddef:${refURL}`); } - @action private async createListingWithAI() { + @action private async createListing() { const command = new OpenCreateListingModalCommand( this.commandService.commandContext, ); @@ -429,19 +437,24 @@ export default class DetailPanel extends Component { throw new Error('targetRealm is required to create listing'); } if (this.isCardInstance) { - const openCardId = this.args.cardInstance?.id; + const openCardIds = this.args.cardInstance?.id + ? [this.args.cardInstance.id] + : []; const codeRef = this.cardInstanceType?.type ? getResolvedCodeRefFromType(this.cardInstanceType.type) : undefined; if (!codeRef) { throw new Error('Cannot create listing: card type not yet loaded'); } - await command.execute({ openCardId, codeRef, targetRealm }); + await command.execute({ openCardIds, codeRef, targetRealm }); } else { const codeRef: ResolvedCodeRef = this.selectedDeclarationAsCodeRef; + const openCardIds = this.args.cardInstance?.id + ? [this.args.cardInstance.id] + : []; await command.execute({ codeRef, - openCardId: this.args.cardInstance?.id, + openCardIds, targetRealm, }); } diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index 483172bfb0a..72dcc015156 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -129,6 +129,14 @@ export function isFileDef(def: any): def is typeof FileDef { return isBaseDef(def) && 'isFileDef' in def; } +export function isListingDef(def: any): boolean { + return isCardDef(def) && 'isListingDef' in def; +} + +export function isListingInstance(card: any): boolean { + return isListingDef(card?.constructor); +} + export function isFieldInstance( fieldInstance: any, ): fieldInstance is T { From beab058e7bc0636cca50a9111a5e1eada15322c2 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 15:34:02 +0800 Subject: [PATCH 12/34] update test --- .../open-create-listing-modal-test.gts | 20 ++++---- .../components/create-listing-modal-test.gts | 46 +++---------------- 2 files changed, 17 insertions(+), 49 deletions(-) diff --git a/packages/host/tests/integration/commands/open-create-listing-modal-test.gts b/packages/host/tests/integration/commands/open-create-listing-modal-test.gts index 8ddcc276150..b8fec97ba13 100644 --- a/packages/host/tests/integration/commands/open-create-listing-modal-test.gts +++ b/packages/host/tests/integration/commands/open-create-listing-modal-test.gts @@ -50,20 +50,20 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { name: 'Pet', }, targetRealm: testRealmURL, - openCardId: `${testRealmURL}Pet/mango`, + openCardIds: [`${testRealmURL}Pet/mango`], } as never); - assert.deepEqual(operatorModeStateService.createListingModalRequest, { + assert.deepEqual(operatorModeStateService.createListingModalPayload, { codeRef: { module: `${testRealmURL}pet`, name: 'Pet', }, targetRealm: testRealmURL, - openCardId: `${testRealmURL}Pet/mango`, + openCardIds: [`${testRealmURL}Pet/mango`], }); }); - test('stores modal request without openCardId', async function (assert) { + test('stores modal request without openCardIds', async function (assert) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); @@ -79,16 +79,16 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { targetRealm: testRealmURL, } as never); - let request = operatorModeStateService.createListingModalRequest; + let request = operatorModeStateService.createListingModalPayload; assert.deepEqual(request?.codeRef, { module: `${testRealmURL}pet`, name: 'Pet', }); assert.strictEqual(request?.targetRealm, testRealmURL); assert.strictEqual( - request?.openCardId, + request?.openCardIds, undefined, - 'openCardId is absent from state', + 'openCardIds is absent from state', ); }); @@ -106,18 +106,18 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { name: 'Pet', }, targetRealm: testRealmURL, - openCardId: `${testRealmURL}Pet/mango`, + openCardIds: [`${testRealmURL}Pet/mango`], } as never); assert.ok( - operatorModeStateService.createListingModalRequest, + operatorModeStateService.createListingModalPayload, 'request is set after execute', ); operatorModeStateService.dismissCreateListingModal(); assert.strictEqual( - operatorModeStateService.createListingModalRequest, + operatorModeStateService.createListingModalPayload, undefined, 'request is cleared after dismissCreateListingModal', ); 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 ac5cbd1e01f..d4818f8cfa3 100644 --- a/packages/host/tests/integration/components/create-listing-modal-test.gts +++ b/packages/host/tests/integration/components/create-listing-modal-test.gts @@ -64,7 +64,7 @@ module('Integration | components | create-listing-modal', function (hooks) { .includesText(ctx.realmName); }); - test('source select defaults to definition when no openCardId', async function (assert) { + test('shows codeRef in modal', async function (assert) { await renderComponent( class TestDriver extends GlimmerComponent { @@ -78,35 +78,7 @@ module('Integration | components | create-listing-modal', function (hooks) { await waitFor('[data-test-create-listing-modal]'); - assert - .dom( - '.ember-power-select-trigger [data-test-create-listing-definition-option]', - ) - .exists('definition option is selected in the trigger'); - }); - - test('source select pre-selects instance when openCardId is provided', async function (assert) { - let openCardId = `${testRealmURL}Pet/mango`; - - await renderComponent( - class TestDriver extends GlimmerComponent { - - }, - ); - - ctx.operatorModeStateService.showCreateListingModal({ - codeRef: { module: `${testRealmURL}pet`, name: 'Pet' }, - targetRealm: testRealmURL, - openCardId, - }); - - await waitFor('[data-test-create-listing-modal]'); - - assert - .dom( - `.ember-power-select-trigger [data-test-create-listing-instance-option="${openCardId}"]`, - ) - .exists('instance option matching openCardId is selected in the trigger'); + assert.dom('[data-test-create-listing-coderef]').includesText('Pet'); }); test('cancel button closes modal', async function (assert) { @@ -127,27 +99,23 @@ module('Integration | components | create-listing-modal', function (hooks) { assert.dom('[data-test-create-listing-modal]').doesNotExist(); }); - test('shows error when listing creation fails', async function (assert) { + test('shows create button', async function (assert) { await renderComponent( class TestDriver extends GlimmerComponent { }, ); - // Provide an incomplete codeRef (missing 'module') so the create task - // fails synchronously with a descriptive error message. ctx.operatorModeStateService.showCreateListingModal({ - codeRef: { name: 'Pet' } as any, + codeRef: { module: `${testRealmURL}pet`, name: 'Pet' }, targetRealm: testRealmURL, }); await waitFor('[data-test-create-listing-modal]'); - await click('[data-test-create-listing-confirm-button]'); - - await waitFor('[data-test-create-listing-error]'); assert - .dom('[data-test-create-listing-error]') - .includesText('Cannot create listing without a resolved code ref'); + .dom('[data-test-create-listing-confirm-button]') + .includesText('Create'); + assert.dom('[data-test-create-listing-confirm-button]').isNotDisabled(); }); }); From 13a49f9ce3e00ae82da95ebb4a8dd290192f431b Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 15:40:49 +0800 Subject: [PATCH 13/34] remove toast --- packages/host/app/components/toast.gts | 162 ------------------------- 1 file changed, 162 deletions(-) delete mode 100644 packages/host/app/components/toast.gts diff --git a/packages/host/app/components/toast.gts b/packages/host/app/components/toast.gts deleted file mode 100644 index 8bb98a16284..00000000000 --- a/packages/host/app/components/toast.gts +++ /dev/null @@ -1,162 +0,0 @@ -import { on } from '@ember/modifier'; - -import Component from '@glimmer/component'; - -import { - BoxelButton, - IconButton, - LoadingIndicator, -} from '@cardstack/boxel-ui/components'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import { IconX } from '@cardstack/boxel-ui/icons'; - -import AlertCircle from '@cardstack/boxel-icons/alert-circle'; -import CircleCheck from '@cardstack/boxel-icons/circle-check'; - -interface Signature { - Element: HTMLDivElement; - Args: { - isVisible: boolean; - onDismiss: () => void; - status?: 'loading' | 'success' | 'error'; - ctaLabel?: string; - onCtaClick?: () => void; - }; - Blocks: { - header: []; - default: []; - }; -} - -export default class Toast extends Component { - -} From 0a12266f32fd32f5fda76128d3cee0d8de80d0b1 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 15:42:49 +0800 Subject: [PATCH 14/34] menu item should pass openCardIds --- packages/base/menu-items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/menu-items.ts b/packages/base/menu-items.ts index fdfb1b3544e..efe10ce45d9 100644 --- a/packages/base/menu-items.ts +++ b/packages/base/menu-items.ts @@ -167,7 +167,7 @@ export function getDefaultCardMenuItems( } await new OpenCreateListingModalCommand(params.commandContext).execute({ codeRef, - openCardId: cardId, + openCardIds: [cardId], targetRealm, }); }, From 368cfd9b998e95522296d9bce7e489bc7e8f73ca Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 15:53:13 +0800 Subject: [PATCH 15/34] fix test --- .../open-create-listing-modal-test.gts | 24 +++++----- .../components/create-listing-modal-test.gts | 44 +++++-------------- 2 files changed, 22 insertions(+), 46 deletions(-) diff --git a/packages/host/tests/integration/commands/open-create-listing-modal-test.gts b/packages/host/tests/integration/commands/open-create-listing-modal-test.gts index b8fec97ba13..c786bb36b13 100644 --- a/packages/host/tests/integration/commands/open-create-listing-modal-test.gts +++ b/packages/host/tests/integration/commands/open-create-listing-modal-test.gts @@ -36,7 +36,7 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { ); }); - test('stores modal request in operator mode state', async function (assert) { + test('stores modal payload in operator mode state', async function (assert) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); @@ -63,7 +63,7 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { }); }); - test('stores modal request without openCardIds', async function (assert) { + test('stores modal payload without openCardIds', async function (assert) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); @@ -79,20 +79,20 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { targetRealm: testRealmURL, } as never); - let request = operatorModeStateService.createListingModalPayload; - assert.deepEqual(request?.codeRef, { + let payload = operatorModeStateService.createListingModalPayload; + assert.deepEqual(payload?.codeRef, { module: `${testRealmURL}pet`, name: 'Pet', }); - assert.strictEqual(request?.targetRealm, testRealmURL); - assert.strictEqual( - request?.openCardIds, - undefined, - 'openCardIds is absent from state', + assert.strictEqual(payload?.targetRealm, testRealmURL); + assert.deepEqual( + payload?.openCardIds, + [], + 'openCardIds is empty when not provided', ); }); - test('dismissCreateListingModal clears the request', async function (assert) { + test('dismissCreateListingModal clears the payload', async function (assert) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); @@ -111,7 +111,7 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { assert.ok( operatorModeStateService.createListingModalPayload, - 'request is set after execute', + 'payload is set after execute', ); operatorModeStateService.dismissCreateListingModal(); @@ -119,7 +119,7 @@ module('Integration | commands | open-create-listing-modal', function (hooks) { assert.strictEqual( operatorModeStateService.createListingModalPayload, undefined, - 'request is cleared after dismissCreateListingModal', + 'payload is cleared after dismissCreateListingModal', ); }); }); 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 d4818f8cfa3..250eaa1fc23 100644 --- a/packages/host/tests/integration/components/create-listing-modal-test.gts +++ b/packages/host/tests/integration/components/create-listing-modal-test.gts @@ -1,4 +1,4 @@ -import { click, waitFor } from '@ember/test-helpers'; +import { waitFor } from '@ember/test-helpers'; import GlimmerComponent from '@glimmer/component'; import { module, test } from 'qunit'; @@ -15,17 +15,7 @@ module('Integration | components | create-listing-modal', function (hooks) { let noop = () => {}; - test('modal is hidden by default', async function (assert) { - await renderComponent( - class TestDriver extends GlimmerComponent { - - }, - ); - - assert.dom('[data-test-create-listing-modal]').doesNotExist(); - }); - - test('modal renders when request is set', async function (assert) { + test('modal renders when payload is set', async function (assert) { await renderComponent( class TestDriver extends GlimmerComponent { @@ -81,25 +71,7 @@ module('Integration | components | create-listing-modal', function (hooks) { assert.dom('[data-test-create-listing-coderef]').includesText('Pet'); }); - test('cancel button closes modal', async function (assert) { - await renderComponent( - class TestDriver extends GlimmerComponent { - - }, - ); - - ctx.operatorModeStateService.showCreateListingModal({ - codeRef: { module: `${testRealmURL}pet`, name: 'Pet' }, - targetRealm: testRealmURL, - }); - - await waitFor('[data-test-create-listing-modal]'); - await click('[data-test-create-listing-cancel-button]'); - - assert.dom('[data-test-create-listing-modal]').doesNotExist(); - }); - - test('shows create button', async function (assert) { + test('shows example instances for a card type', async function (assert) { await renderComponent( class TestDriver extends GlimmerComponent { @@ -112,10 +84,14 @@ module('Integration | components | create-listing-modal', function (hooks) { }); await waitFor('[data-test-create-listing-modal]'); + await waitFor('[data-test-examples-container]'); + assert.dom('[data-test-examples-container]').exists(); + assert + .dom(`[data-test-create-listing-example="${testRealmURL}Pet/mango"]`) + .exists('shows the Pet instance as an example'); assert - .dom('[data-test-create-listing-confirm-button]') - .includesText('Create'); - assert.dom('[data-test-create-listing-confirm-button]').isNotDisabled(); + .dom(`[data-test-card-catalog-item-selected="true"]`) + .exists('example is auto-selected'); }); }); From 5336b8ca509058a8796f3856dbcca2e1ac276540 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 16:01:20 +0800 Subject: [PATCH 16/34] fix test --- .../host/tests/acceptance/code-submode/inspector-test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/host/tests/acceptance/code-submode/inspector-test.ts b/packages/host/tests/acceptance/code-submode/inspector-test.ts index 2b8615ef2e9..1e711759cc8 100644 --- a/packages/host/tests/acceptance/code-submode/inspector-test.ts +++ b/packages/host/tests/acceptance/code-submode/inspector-test.ts @@ -2729,11 +2729,16 @@ export class ExportedCard extends ExportedCardParent { assert .dom('[data-test-create-listing-modal]') .exists('confirmation modal appears after clicking Create Listing'); + + await waitFor('[data-test-create-listing-examples]'); + assert + .dom('[data-test-select-all]') + .isChecked('select all checkbox is checked'); assert .dom( - `.ember-power-select-trigger [data-test-create-listing-instance-option]`, + '[data-test-create-listing-examples] [data-test-card-catalog-item-selected="true"]', ) - .exists('instance option is pre-selected in the source dropdown'); + .exists({ count: 2 }, 'all instances are pre-selected'); }); test('cancel button in Create Listing modal closes the modal', async function (assert) { From 065d64d861a8f50dcbb0801f7b5e9850af9914a1 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 16:23:11 +0800 Subject: [PATCH 17/34] remove accidentally added submodule reference Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/catalog-new/contents | 1 - 1 file changed, 1 deletion(-) delete mode 160000 packages/catalog-new/contents diff --git a/packages/catalog-new/contents b/packages/catalog-new/contents deleted file mode 160000 index c3845a0cb6e..00000000000 --- a/packages/catalog-new/contents +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c3845a0cb6e2a59d9995c3857904d518979fae85 From 87624ce0e05a5999fce3ac248e9c810fdbb1d3d8 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 16:25:17 +0800 Subject: [PATCH 18/34] fix lint --- packages/host/app/commands/listing-create.ts | 4 +--- .../app/components/operator-mode/create-listing-modal.gts | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index 7efde970b80..4b3eb2d4daa 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -112,7 +112,6 @@ export default class ListingCreateCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ListingCreateInput, ): Promise { - const cardAPI = await this.loadCardAPI(); let { openCardIds, codeRef, targetRealm } = input; if (!codeRef) { @@ -402,8 +401,7 @@ export default class ListingCreateCommand extends HostBaseCommand< await Promise.all( openCardIds.map(async (openCardId) => { try { - const instance = - await this.store.get(openCardId); + const instance = await this.store.get(openCardId); if (isCardInstance(instance)) { addCard(instance as CardAPI.CardDef); } else { 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 bf818544881..3100c371b59 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -24,6 +24,7 @@ import { import ListingCreateCommand from '@cardstack/host/commands/listing-create'; import ItemButton from '@cardstack/host/components/card-search/item-button'; +import type { NewCardArgs } from '@cardstack/host/components/card-search/utils'; import ModalContainer from '@cardstack/host/components/modal-container'; import { SelectedTypePill } from '@cardstack/host/components/operator-mode/create-file-modal'; import { Submodes } from '@cardstack/host/components/submode-switcher'; @@ -131,7 +132,7 @@ export default class CreateListingModal extends Component { } private get instances(): CardDef[] { - return this.instancesSearch.instances; + return this.instancesSearch.instances as CardDef[]; } private get hasInstances(): boolean { @@ -155,7 +156,10 @@ export default class CreateListingModal extends Component { ); } - @action private onSelectExample(selection: string) { + @action private onSelectExample(selection: string | NewCardArgs) { + if (typeof selection !== 'string') { + return; + } // First manual toggle: start from "all selected" state let current = this.selectedExampleIds ?? new Set(this.instances.map((i) => i.id)); From 83b9a0365778977a86a547731b5f7de6c7a9a27b Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 18:07:57 +0800 Subject: [PATCH 19/34] fix test --- packages/catalog-realm/catalog-app/listing/listing.gts | 2 +- packages/host/tests/unit/card-def-menu-items-test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index 05d7369cea3..58f0e0f43cb 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -642,7 +642,7 @@ export class Listing extends CardDef { [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { let menuItems = super [getMenuItems](params) - .filter((item) => item.label?.toLowerCase() !== 'create listing with ai'); + .filter((item) => item.label?.toLowerCase() !== 'create listing'); if (params.menuContext === 'interact') { const extra = this.getGenerateExampleMenuItem(params); if (extra) { diff --git a/packages/host/tests/unit/card-def-menu-items-test.ts b/packages/host/tests/unit/card-def-menu-items-test.ts index 922796178f0..f35014355fb 100644 --- a/packages/host/tests/unit/card-def-menu-items-test.ts +++ b/packages/host/tests/unit/card-def-menu-items-test.ts @@ -121,13 +121,13 @@ module('Unit | CardDef menu items', function (hooks) { }); let hasCreateListing = items.some((i: MenuItemOptions) => - i.label.includes('Create Listing with AI'), + i.label.includes('Create Listing'), ); let hasSampleDataTagged = items.some((i: MenuItemOptions) => (i.tags || []).includes('playground-sample-data'), ); - assert.ok(hasCreateListing, 'contains Create Listing with AI'); + assert.ok(hasCreateListing, 'contains Create Listing'); assert.ok( hasSampleDataTagged, 'contains items tagged playground-sample-data', From 5e3dd97bf43a7792cc215b16e123d122ae8dd97c Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 18 Mar 2026 21:24:07 +0800 Subject: [PATCH 20/34] address codex feedback --- .../app/components/operator-mode/create-listing-modal.gts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 3100c371b59..5cd803c1fbd 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -63,13 +63,10 @@ export default class CreateListingModal extends Component { } let targetRealm = payload.targetRealm; - // null means all selected → use all instance IDs let openCardIds = this.selectedExampleIds === null ? this.instances.map((i) => i.id) - : this.selectedExampleIds.size > 0 - ? [...this.selectedExampleIds] - : (payload.openCardIds ?? []); + : [...this.selectedExampleIds]; let result = await new ListingCreateCommand( this.commandService.commandContext, @@ -99,6 +96,7 @@ export default class CreateListingModal extends Component { await backgroundWork; } + this.selectedExampleIds = null; this.operatorModeStateService.dismissCreateListingModal(); }); From f6892dbd4cb91619bea2c57693fd6cafaf4f54ba Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 19 Mar 2026 17:00:45 +0800 Subject: [PATCH 21/34] Refactor create-listing modal to use CardInstancePicker component - Extract CardInstancePicker as a thin Picker wrapper (mirrors RealmPicker/TypePicker pattern: selectAll option, passthrough onChange) - Refactor CreateListingModal to own selection state as PickerOption[] instead of string[], with effectiveSelected derived from user interaction or initial payload (openCardIds / first instance) - Fix autoLinkExample in ListingCreateCommand to respect user's explicit selection instead of auto-filling additional examples via LLM - Update create-listing-modal tests for new picker UI and add coverage for auto-select behavior (from instance vs from module) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host/app/commands/listing-create.ts | 9 +- .../components/card-instance-picker/index.gts | 54 ++++ .../operator-mode/create-listing-modal.gts | 298 ++++++------------ .../components/create-listing-modal-test.gts | 52 ++- 4 files changed, 206 insertions(+), 207 deletions(-) create mode 100644 packages/host/app/components/card-instance-picker/index.gts diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index 4b3eb2d4daa..4ec696bbfda 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -442,8 +442,15 @@ export default class ListingCreateCommand extends HostBaseCommand< } } + // Only auto-fill additional examples when the user didn't explicitly choose + const userExplicitlyChose = openCardIds && openCardIds.length > 0; const MAX_EXAMPLES = 4; - if (codeRef && uniqueById.size > 0 && uniqueById.size < MAX_EXAMPLES) { + if ( + !userExplicitlyChose && + codeRef && + uniqueById.size > 0 && + uniqueById.size < MAX_EXAMPLES + ) { try { const searchAndChoose = new SearchAndChooseCommand(this.commandContext); const existingIds = Array.from(uniqueById.keys()); diff --git a/packages/host/app/components/card-instance-picker/index.gts b/packages/host/app/components/card-instance-picker/index.gts new file mode 100644 index 00000000000..2fbdee8e7dc --- /dev/null +++ b/packages/host/app/components/card-instance-picker/index.gts @@ -0,0 +1,54 @@ +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; + +import { Picker, type PickerOption } from '@cardstack/boxel-ui/components'; + +interface Signature { + Args: { + options: PickerOption[]; + selected: PickerOption[]; + onChange: (selected: PickerOption[]) => void; + label?: string; + maxSelectedDisplay?: number; + placeholder?: string; + }; + Blocks: {}; + Element: HTMLElement; +} + +export default class CardInstancePicker extends Component { + @cached + get selectAllOption(): PickerOption { + return { + id: 'select-all', + label: `Select All (${this.args.options.length})`, + shortLabel: 'All', + type: 'select-all', + }; + } + + get allOptions(): PickerOption[] { + return [this.selectAllOption, ...this.args.options]; + } + + get selected(): PickerOption[] { + return this.args.selected.length > 0 + ? this.args.selected + : [this.selectAllOption]; + } + + +} 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 5cd803c1fbd..28229318c63 100644 --- a/packages/host/app/components/operator-mode/create-listing-modal.gts +++ b/packages/host/app/components/operator-mode/create-listing-modal.gts @@ -3,17 +3,16 @@ import { action } from '@ember/object'; import { getOwner } from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; +import { cached, tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; import perform from 'ember-concurrency/helpers/perform'; import onKeyMod from 'ember-keyboard/modifiers/on-key'; import { - BoxelInput, Button, FieldContainer, - GridContainer, + type PickerOption, RealmIcon, } from '@cardstack/boxel-ui/components'; @@ -22,9 +21,10 @@ import { type ResolvedCodeRef, } from '@cardstack/runtime-common'; +import { cardTypeIcon } from '@cardstack/runtime-common/helpers/card-type-display-name'; + import ListingCreateCommand from '@cardstack/host/commands/listing-create'; -import ItemButton from '@cardstack/host/components/card-search/item-button'; -import type { NewCardArgs } from '@cardstack/host/components/card-search/utils'; +import CardInstancePicker from '@cardstack/host/components/card-instance-picker'; import ModalContainer from '@cardstack/host/components/modal-container'; import { SelectedTypePill } from '@cardstack/host/components/operator-mode/create-file-modal'; import { Submodes } from '@cardstack/host/components/submode-switcher'; @@ -45,60 +45,25 @@ export default class CreateListingModal extends Component { @service declare private operatorModeStateService: OperatorModeStateService; @service declare private realm: RealmService; - @tracked private selectedExampleIds: Set | null = null; + @tracked private selectedExamples: PickerOption[] = []; private instancesSearch = getSearch(this, getOwner(this)!, () => this.codeRef ? { filter: { type: this.codeRef } } : undefined, ); - private createListing = task(async () => { - let payload = this.payload; - if (!payload) { - throw new Error('Cannot create listing without a modal payload'); - } - - let codeRef = this.codeRef; - if (!codeRef) { - throw new Error('Cannot create listing without a resolved code ref'); - } - - let targetRealm = payload.targetRealm; - let openCardIds = - this.selectedExampleIds === null - ? this.instances.map((i) => i.id) - : [...this.selectedExampleIds]; - - let result = await new ListingCreateCommand( - this.commandService.commandContext, - ).execute({ - codeRef, - targetRealm, - openCardIds, - }); - - // Navigate to the listing in code mode with isolated preview - let cardUrl = result?.listing?.id; - if (cardUrl) { - if (this.operatorModeStateService.workspaceChooserOpened) { - this.operatorModeStateService.closeWorkspaceChooser(); - } - await this.operatorModeStateService.updateSubmode(Submodes.Code); - await this.operatorModeStateService.updateCodePath( - new URL(cardUrl + '.json'), - 'preview', - ); - this.operatorModeStateService.updateCardPreviewFormat('isolated'); - } - - // Keep modal open while background auto-patching runs - let backgroundWork = (result as any)?.backgroundWork; - if (backgroundWork) { - await backgroundWork; - } + private get instances(): CardDef[] { + return this.instancesSearch.instances as CardDef[]; + } - this.selectedExampleIds = null; - this.operatorModeStateService.dismissCreateListingModal(); - }); + @cached + get instanceOptions(): PickerOption[] { + return this.instances.map((instance) => ({ + id: instance.id, + label: instance.cardTitle ?? instance.id, + icon: cardTypeIcon(instance), + type: 'option' as const, + })); + } private get payload() { return this.operatorModeStateService.createListingModalPayload; @@ -129,67 +94,83 @@ export default class CreateListingModal extends Component { return this.payload?.openCardIds?.[0] ?? this.codeRef?.module ?? ''; } - private get instances(): CardDef[] { - return this.instancesSearch.instances as CardDef[]; + private get selectedExampleURLs(): string[] { + const selected = this.effectiveSelected; + const hasSelectAll = selected.some((opt) => opt.type === 'select-all'); + if (hasSelectAll || selected.length === 0) { + return this.instances.map((i) => i.id); + } + return selected.map((opt) => opt.id).filter(Boolean); } - private get hasInstances(): boolean { - return this.instances.length > 0; + private get initialSelected(): PickerOption[] { + const openCardIds = this.payload?.openCardIds; + if (openCardIds?.length) { + return this.instanceOptions.filter((opt) => openCardIds.includes(opt.id)); + } + // Opened from module (no specific instance) → auto-select first instance + let first = this.instanceOptions[0]; + return first ? [first] : []; } - // null = user hasn't manually toggled anything → all selected by default - private get hasManualSelection(): boolean { - return this.selectedExampleIds !== null; + private get effectiveSelected(): PickerOption[] { + return this.selectedExamples.length > 0 + ? this.selectedExamples + : this.initialSelected; } - private get allSelected(): boolean { - if (!this.hasInstances) { - return false; - } - if (!this.hasManualSelection) { - return true; - } - return this.instances.every((instance) => - this.selectedExampleIds!.has(instance.id), - ); + @action private onExampleChange(selected: PickerOption[]) { + this.selectedExamples = selected; } - @action private onSelectExample(selection: string | NewCardArgs) { - if (typeof selection !== 'string') { - return; + private createListing = task(async () => { + let payload = this.payload; + if (!payload) { + throw new Error('Cannot create listing without a modal payload'); } - // First manual toggle: start from "all selected" state - let current = - this.selectedExampleIds ?? new Set(this.instances.map((i) => i.id)); - let next = new Set(current); - if (next.has(selection)) { - next.delete(selection); - } else { - next.add(selection); + + let codeRef = this.codeRef; + if (!codeRef) { + throw new Error('Cannot create listing without a resolved code ref'); } - this.selectedExampleIds = next; - } - private isSelected = (id: string): boolean => { - // null means no manual selection → all selected - if (this.selectedExampleIds === null) { - return true; + let targetRealm = payload.targetRealm; + let openCardIds = this.selectedExampleURLs; + + let result = await new ListingCreateCommand( + this.commandService.commandContext, + ).execute({ + codeRef, + targetRealm, + openCardIds, + }); + + // Navigate to the listing in code mode with isolated preview + let cardUrl = result?.listing?.id; + if (cardUrl) { + if (this.operatorModeStateService.workspaceChooserOpened) { + this.operatorModeStateService.closeWorkspaceChooser(); + } + await this.operatorModeStateService.updateSubmode(Submodes.Code); + await this.operatorModeStateService.updateCodePath( + new URL(cardUrl + '.json'), + 'preview', + ); + this.operatorModeStateService.updateCardPreviewFormat('isolated'); } - return this.selectedExampleIds.has(id); - }; - @action private selectAll() { - this.selectedExampleIds = new Set( - this.instances.map((instance) => instance.id), - ); - } + // Keep modal open while background auto-patching runs + let backgroundWork = (result as any)?.backgroundWork; + if (backgroundWork) { + await backgroundWork; + } - @action private clearAll() { - this.selectedExampleIds = new Set(); - } + this.selectedExamples = []; + this.operatorModeStateService.dismissCreateListingModal(); + }); @action private onClose() { - this.selectedExampleIds = null; + this.selectedExamples = []; this.operatorModeStateService.dismissCreateListingModal(); } @@ -210,10 +191,7 @@ export default class CreateListingModal extends Component { catalogs.

-
+
{{#if this.realmInfo}} {{this.realmInfo.name}} @@ -221,7 +199,7 @@ export default class CreateListingModal extends Component {
- +
{
- {{#if this.hasInstances}} - -
-
- - -
-

- Select the examples to be linked to the listing. These help - others understand how to use your code. -

- +
+ - {{#each this.instances as |instance|}} - - {{/each}} - + />
{{/if}} @@ -360,74 +302,26 @@ export default class CreateListingModal extends Component { } .field { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + align-items: start; gap: var(--boxel-sp-xxxs) var(--horizontal-gap); } .field :deep(.label-container) { width: 8rem; + flex-shrink: 0; } .field :deep(.content) { flex-grow: 1; - min-width: 13rem; + min-width: 0; } .field-contents { display: flex; align-items: center; - min-height: 2.25rem; - flex-wrap: wrap; - gap: var(--boxel-sp-xxxs); - } - .realm-value { - gap: var(--boxel-sp-xxxs); + gap: var(--horizontal-gap); } .realm-icon { --boxel-realm-icon-size: 1rem; } - .examples-field { - margin-top: var(--boxel-sp-sm); - } - .examples-container { - border: 1px solid var(--boxel-300); - border-radius: var(--boxel-border-radius); - padding: var(--boxel-sp-sm); - width: 100%; - } - .examples-header { - display: flex; - align-items: center; - justify-content: space-between; - } - .select-all-label { - display: flex; - align-items: center; - gap: var(--boxel-sp-xxxs); - font: var(--boxel-font-sm); - font-weight: 600; - cursor: pointer; - } - .select-all-label :deep(.input-container) { - --boxel-checkbox-size: 16px; - } - .clear-all-button { - --boxel-button-font: var(--boxel-font-xs); - --boxel-button-text-color: var(--boxel-500); - text-decoration: underline; - } - .clear-all-button:hover { - --boxel-button-text-color: var(--boxel-dark); - } - .examples-description { - font: var(--boxel-font-xs); - color: var(--boxel-500); - margin: var(--boxel-sp-xxxs) 0 var(--boxel-sp-sm); - } - .examples-grid { - width: 100%; - gap: var(--boxel-sp-xs); - } - .example-card { - height: 60px; - } .footer-buttons { display: flex; align-items: center; 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 250eaa1fc23..766b683b710 100644 --- a/packages/host/tests/integration/components/create-listing-modal-test.gts +++ b/packages/host/tests/integration/components/create-listing-modal-test.gts @@ -88,10 +88,54 @@ module('Integration | components | create-listing-modal', function (hooks) { assert.dom('[data-test-examples-container]').exists(); assert - .dom(`[data-test-create-listing-example="${testRealmURL}Pet/mango"]`) - .exists('shows the Pet instance as an example'); + .dom('[data-test-card-instance-picker]') + .exists('shows the card instance picker for examples'); + }); + + test('auto-selects the instance when opened from an instance', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreateListingModal({ + codeRef: { module: `${testRealmURL}pet`, name: 'Pet' }, + targetRealm: testRealmURL, + openCardIds: [`${testRealmURL}Pet/mango`], + }); + + await waitFor('[data-test-create-listing-modal]'); + await waitFor('[data-test-card-instance-picker]'); + + assert + .dom('[data-test-boxel-picker-selected-item]') + .exists('picker has a selected item'); + assert + .dom('[data-test-boxel-picker-selected-item] .picker-selected-item__text') + .hasText('Mango', 'the opened instance is auto-selected'); + }); + + test('auto-selects the first instance when opened from a module', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreateListingModal({ + codeRef: { module: `${testRealmURL}pet`, name: 'Pet' }, + targetRealm: testRealmURL, + }); + + await waitFor('[data-test-create-listing-modal]'); + await waitFor('[data-test-card-instance-picker]'); + + assert + .dom('[data-test-boxel-picker-selected-item]') + .exists({ count: 1 }, 'picker has exactly one selected item'); assert - .dom(`[data-test-card-catalog-item-selected="true"]`) - .exists('example is auto-selected'); + .dom('[data-test-boxel-picker-selected-item] .picker-selected-item__text') + .exists('first instance is auto-selected'); }); }); From 66fe7215935b8730531023693734eac51278087d Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 19 Mar 2026 22:36:41 +0800 Subject: [PATCH 22/34] fix inspector test --- .../tests/acceptance/code-submode/inspector-test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/host/tests/acceptance/code-submode/inspector-test.ts b/packages/host/tests/acceptance/code-submode/inspector-test.ts index 1e711759cc8..646d2e2ba6d 100644 --- a/packages/host/tests/acceptance/code-submode/inspector-test.ts +++ b/packages/host/tests/acceptance/code-submode/inspector-test.ts @@ -2732,13 +2732,15 @@ export class ExportedCard extends ExportedCardParent { await waitFor('[data-test-create-listing-examples]'); assert - .dom('[data-test-select-all]') - .isChecked('select all checkbox is checked'); + .dom( + '[data-test-create-listing-examples] [data-test-boxel-picker-selected-item]', + ) + .exists({ count: 1 }, 'the opened instance is pre-selected'); assert .dom( - '[data-test-create-listing-examples] [data-test-card-catalog-item-selected="true"]', + '[data-test-create-listing-examples] [data-test-boxel-picker-selected-item]', ) - .exists({ count: 2 }, 'all instances are pre-selected'); + .containsText('Mango', 'mango instance is pre-selected'); }); test('cancel button in Create Listing modal closes the modal', async function (assert) { From 5ad688f630f260601b4a9a59abf0790cf968906a Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 19 Mar 2026 22:37:34 +0800 Subject: [PATCH 23/34] temporaily point GITHUB_TEAM_REVIEWERS to ecosystem team only --- packages/bot-runner/lib/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bot-runner/lib/github.ts b/packages/bot-runner/lib/github.ts index d5f3775c151..00b85efe511 100644 --- a/packages/bot-runner/lib/github.ts +++ b/packages/bot-runner/lib/github.ts @@ -1,5 +1,5 @@ const GITHUB_API_BASE = 'https://api.github.com'; -const GITHUB_TEAM_REVIEWERS = ['ecosystem-team', 'boxel-developers']; +const GITHUB_TEAM_REVIEWERS = ['ecosystem-team']; export interface OpenPullRequestParams { owner: string; From ac4203f9b86b983d31dd19173b80f45149287682 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 19 Mar 2026 22:39:57 +0800 Subject: [PATCH 24/34] revert changes --- packages/bot-runner/lib/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bot-runner/lib/github.ts b/packages/bot-runner/lib/github.ts index 00b85efe511..d5f3775c151 100644 --- a/packages/bot-runner/lib/github.ts +++ b/packages/bot-runner/lib/github.ts @@ -1,5 +1,5 @@ const GITHUB_API_BASE = 'https://api.github.com'; -const GITHUB_TEAM_REVIEWERS = ['ecosystem-team']; +const GITHUB_TEAM_REVIEWERS = ['ecosystem-team', 'boxel-developers']; export interface OpenPullRequestParams { owner: string; From 89e30fdc4657baadf361868770b01f22a8de3d23 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 18:19:57 +0800 Subject: [PATCH 25/34] Update CommandRunner to use configurable submission realm URL and username instead of the Matrix bot user identity. --- packages/bot-runner/lib/command-runner.ts | 10 ++++---- packages/bot-runner/lib/github.ts | 2 +- packages/bot-runner/lib/timeline-handler.ts | 7 +++++- packages/bot-runner/main.ts | 7 ++++++ .../bot-runner/tests/command-runner-test.ts | 23 ++++++++++++------- .../catalog-realm/commands/create-pr-card.ts | 14 +++++++---- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/bot-runner/lib/command-runner.ts b/packages/bot-runner/lib/command-runner.ts index 9c103ae2c29..2cfd378ff01 100644 --- a/packages/bot-runner/lib/command-runner.ts +++ b/packages/bot-runner/lib/command-runner.ts @@ -26,7 +26,8 @@ export class CommandRunner { private createListingPRHandler: CreateListingPRHandler; constructor( - private submissionBotUserId: string, + private submissionRealmUrl: string, + private submissionRealmUsername: string, private dbAdapter: DBAdapter, private queuePublisher: QueuePublisher, githubClient: GitHubClient, @@ -190,13 +191,12 @@ export class CommandRunner { submissionCardUrl: string; prResult: CreatedListingPRResult; }): Promise { - let submissionRealm = new URL('/submissions/', realmURL).href; let prCardResult = await this.enqueueRunCommand({ - runAs: this.submissionBotUserId, - realmURL: submissionRealm, + runAs: this.submissionRealmUsername, + realmURL: this.submissionRealmUrl, command: CREATE_PR_CARD_COMMAND, commandInput: { - realm: submissionRealm, + realm: this.submissionRealmUrl, prNumber: prResult.prNumber, prUrl: prResult.prUrl, prTitle: prResult.prTitle, diff --git a/packages/bot-runner/lib/github.ts b/packages/bot-runner/lib/github.ts index d5f3775c151..00b85efe511 100644 --- a/packages/bot-runner/lib/github.ts +++ b/packages/bot-runner/lib/github.ts @@ -1,5 +1,5 @@ const GITHUB_API_BASE = 'https://api.github.com'; -const GITHUB_TEAM_REVIEWERS = ['ecosystem-team', 'boxel-developers']; +const GITHUB_TEAM_REVIEWERS = ['ecosystem-team']; export interface OpenPullRequestParams { owner: string; diff --git a/packages/bot-runner/lib/timeline-handler.ts b/packages/bot-runner/lib/timeline-handler.ts index 5be51a16ade..5aaf0fa4460 100644 --- a/packages/bot-runner/lib/timeline-handler.ts +++ b/packages/bot-runner/lib/timeline-handler.ts @@ -19,6 +19,8 @@ export interface BotRegistration { export interface TimelineHandlerOptions { authUserId: string; + submissionRealmUrl: string; + submissionRealmUsername: string; dbAdapter: DBAdapter; queuePublisher: QueuePublisher; githubClient: GitHubClient; @@ -27,13 +29,16 @@ export interface TimelineHandlerOptions { export function onTimelineEvent({ authUserId, + submissionRealmUrl, + submissionRealmUsername, dbAdapter, queuePublisher, githubClient, startTime, }: TimelineHandlerOptions) { let commandRunner = new CommandRunner( - authUserId, + submissionRealmUrl, + submissionRealmUsername, dbAdapter, queuePublisher, githubClient, diff --git a/packages/bot-runner/main.ts b/packages/bot-runner/main.ts index bb2fe0e6244..5f74c0bcedb 100644 --- a/packages/bot-runner/main.ts +++ b/packages/bot-runner/main.ts @@ -15,6 +15,11 @@ const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008'; const submissionBotUsername = process.env.SUBMISSION_BOT_USERNAME || 'submissionbot'; const botPassword = process.env.SUBMISSION_BOT_PASSWORD || 'password'; +const realmServerUrl = process.env.REALM_SERVER_URL || 'http://localhost:4201'; +const submissionRealmUrl = + process.env.SUBMISSION_REALM_URL || `${realmServerUrl}/submissions/`; +const submissionRealmUsername = + process.env.SUBMISSION_REALM_USERNAME || 'submission_realm'; (async () => { let client = createClient({ @@ -63,6 +68,8 @@ const botPassword = process.env.SUBMISSION_BOT_PASSWORD || 'password'; let handleTimelineEvent = onTimelineEvent({ authUserId: auth.user_id, + submissionRealmUrl, + submissionRealmUsername, dbAdapter, queuePublisher, githubClient, diff --git a/packages/bot-runner/tests/command-runner-test.ts b/packages/bot-runner/tests/command-runner-test.ts index 6d1d40dbb56..ae9b39ac463 100644 --- a/packages/bot-runner/tests/command-runner-test.ts +++ b/packages/bot-runner/tests/command-runner-test.ts @@ -9,6 +9,9 @@ import type { import type { GitHubClient } from '../lib/github'; import { CommandRunner } from '../lib/command-runner'; +const SUBMISSION_REALM_URL = 'http://localhost:4201/submissions/'; +const SUBMISSION_REALM_USERNAME = 'submission_realm'; + module('command runner', () => { test('enqueues run-command job for matching trigger', async (assert) => { let publishedJobs: unknown[] = []; @@ -65,7 +68,8 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_REALM_URL, + SUBMISSION_REALM_USERNAME, dbAdapter, queuePublisher, githubClient, @@ -203,7 +207,8 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_REALM_URL, + SUBMISSION_REALM_USERNAME, dbAdapter, queuePublisher, githubClient, @@ -234,12 +239,12 @@ module('command runner', () => { assert.deepEqual( (publishedJobs[1] as { args: Record }).args, { - realmURL: 'http://localhost:4201/submissions/', - realmUsername: '@submissionbot:localhost', - runAs: '@submissionbot:localhost', + realmURL: SUBMISSION_REALM_URL, + realmUsername: SUBMISSION_REALM_USERNAME, + runAs: SUBMISSION_REALM_USERNAME, command: '@cardstack/catalog/commands/create-pr-card/default', commandInput: { - realm: 'http://localhost:4201/submissions/', + realm: SUBMISSION_REALM_URL, prNumber: 1, prUrl: 'https://example/pr/1', prTitle: 'Add My Listing Name listing', @@ -351,7 +356,8 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_REALM_URL, + SUBMISSION_REALM_USERNAME, dbAdapter, queuePublisher, githubClient, @@ -448,7 +454,8 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - '@submissionbot:localhost', + SUBMISSION_REALM_URL, + SUBMISSION_REALM_USERNAME, dbAdapter, queuePublisher, githubClient, diff --git a/packages/catalog-realm/commands/create-pr-card.ts b/packages/catalog-realm/commands/create-pr-card.ts index ce6521c7ae6..023c96af0fb 100644 --- a/packages/catalog-realm/commands/create-pr-card.ts +++ b/packages/catalog-realm/commands/create-pr-card.ts @@ -58,11 +58,15 @@ export default class CreatePrCardCommand extends Command< // Link the GitHub PR brand guide theme from the catalog realm let themeCardId = `${catalogRealmUrl}${GITHUB_PR_THEME_PATH}`; - let theme = await new GetCardCommand(this.commandContext).execute({ - cardId: themeCardId, - }); - if (theme) { - card.cardInfo.theme = theme as Theme; + try { + let theme = await new GetCardCommand(this.commandContext).execute({ + cardId: themeCardId, + }); + if (theme) { + card.cardInfo.theme = theme as Theme; + } + } catch { + // Theme is optional — don't block PR card creation if it can't be fetched } // Save the PR card to the submission realm From 1b26b110e4b7056b563e6f6ba588444f9ba85538 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 22:15:49 +0800 Subject: [PATCH 26/34] implement OpenCreatePRModalCommand --- packages/base/command.gts | 1 + .../catalog-app/listing/listing.gts | 5 +- packages/host/app/commands/index.ts | 6 + .../host/app/commands/open-create-pr-modal.ts | 33 +++ .../components/operator-mode/container.gts | 2 + .../operator-mode/create-pr-modal.gts | 242 ++++++++++++++++++ .../services/operator-mode-state-service.ts | 16 ++ 7 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 packages/host/app/commands/open-create-pr-modal.ts create mode 100644 packages/host/app/components/operator-mode/create-pr-modal.gts diff --git a/packages/base/command.gts b/packages/base/command.gts index 8df32a10cf3..bbcb6d5df05 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -386,6 +386,7 @@ export class ListingInstallResult extends CardDef { export class CreateListingPRRequestInput extends CardDef { @field realm = contains(RealmField); @field listingId = contains(StringField); + @field listingName = contains(StringField); } export class ListingCreateInput extends CardDef { diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index 58f0e0f43cb..284158847ea 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -45,7 +45,7 @@ import { listingActions, isReady } from '../resources/listing-actions'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; import ListingGenerateExampleCommand from '@cardstack/boxel-host/commands/listing-generate-example'; import ListingUpdateSpecsCommand from '@cardstack/boxel-host/commands/listing-update-specs'; -import CreateListingPRRequestCommand from '@cardstack/boxel-host/commands/create-listing-pr-request'; +import OpenCreatePRModalCommand from '@cardstack/boxel-host/commands/open-create-pr-modal'; import { getMenuItems } from '@cardstack/runtime-common'; @@ -677,9 +677,10 @@ export class Listing extends CardDef { return { label: 'Make a PR', action: async () => { - await new CreateListingPRRequestCommand(commandContext).execute({ + await new OpenCreatePRModalCommand(commandContext).execute({ listingId: this.id, realm: this[realmURL]!.href, + listingName: this.name, }); }, icon: Package, diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index d125fdf950d..95262a08638 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -38,6 +38,7 @@ import * as ListingUseCommandModule from './listing-use'; import * as OneShotLlmRequestCommandModule from './one-shot-llm-request'; import * as OpenAiAssistantRoomCommandModule from './open-ai-assistant-room'; import * as OpenCreateListingModalCommandModule from './open-create-listing-modal'; +import * as OpenCreatePRModalCommandModule from './open-create-pr-modal'; import * as OpenInInteractModeModule from './open-in-interact-mode'; import * as OpenWorkspaceCommandModule from './open-workspace'; import * as PatchCardInstanceCommandModule from './patch-card-instance'; @@ -261,6 +262,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/open-create-listing-modal', OpenCreateListingModalCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/open-create-pr-modal', + OpenCreatePRModalCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/open-workspace', OpenWorkspaceCommandModule, @@ -410,6 +415,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ OneShotLlmRequestCommandModule.default, OpenAiAssistantRoomCommandModule.default, OpenCreateListingModalCommandModule.default, + OpenCreatePRModalCommandModule.default, OpenInInteractModeModule.default, OpenWorkspaceCommandModule.default, GenerateThemeExampleCommandModule.default, diff --git a/packages/host/app/commands/open-create-pr-modal.ts b/packages/host/app/commands/open-create-pr-modal.ts new file mode 100644 index 00000000000..3ce24697558 --- /dev/null +++ b/packages/host/app/commands/open-create-pr-modal.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class OpenCreatePRModalCommand extends HostBaseCommand< + typeof BaseCommandModule.CreateListingPRRequestInput +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Open create PR confirmation modal'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { CreateListingPRRequestInput } = commandModule; + return CreateListingPRRequestInput; + } + + requireInputFields = ['realm', 'listingId']; + + protected async run( + input: BaseCommandModule.CreateListingPRRequestInput, + ): Promise { + this.operatorModeStateService.showCreatePRModal({ + realm: input.realm, + listingId: input.listingId, + listingName: input.listingName, + }); + } +} diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index b514090ff56..e17cd057923 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -42,6 +42,7 @@ import { Submodes } from '../submode-switcher'; import ChooseFileModal from './choose-file-modal'; import CreateListingModal from './create-listing-modal'; +import CreatePRModal from './create-pr-modal'; import type CardService from '../../services/card-service'; import type CommandService from '../../services/command-service'; @@ -145,6 +146,7 @@ export default class OperatorModeContainer extends Component {
+ diff --git a/packages/host/app/components/operator-mode/create-pr-modal.gts b/packages/host/app/components/operator-mode/create-pr-modal.gts new file mode 100644 index 00000000000..551be865a18 --- /dev/null +++ b/packages/host/app/components/operator-mode/create-pr-modal.gts @@ -0,0 +1,242 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { task } from 'ember-concurrency'; +import perform from 'ember-concurrency/helpers/perform'; +import onKeyMod from 'ember-keyboard/modifiers/on-key'; + +import { + Button, + FieldContainer, + RealmIcon, +} from '@cardstack/boxel-ui/components'; + +import CreateListingPRRequestCommand from '@cardstack/host/commands/bot-requests/create-listing-pr-request'; +import ModalContainer from '@cardstack/host/components/modal-container'; + +import type CommandService from '@cardstack/host/services/command-service'; +import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; +import type RealmService from '@cardstack/host/services/realm'; + +interface Signature { + Args: {}; +} + +export default class CreatePRModal extends Component { + @service declare private commandService: CommandService; + @service declare private operatorModeStateService: OperatorModeStateService; + @service declare private realm: RealmService; + + @tracked private isSubmitted = false; + + private get payload() { + return this.operatorModeStateService.createPRModalPayload; + } + + private get isModalOpen() { + return Boolean(this.payload); + } + + private get realmInfo() { + let payload = this.payload; + if (!payload) { + return undefined; + } + return this.realm.info(payload.realm); + } + + private get listingName(): string { + return this.payload?.listingName ?? 'Listing'; + } + + private createPR = task(async () => { + let payload = this.payload; + if (!payload) { + throw new Error('Cannot create PR without a modal payload'); + } + + await new CreateListingPRRequestCommand( + this.commandService.commandContext, + ).execute({ + listingId: payload.listingId, + realm: payload.realm, + }); + + this.isSubmitted = true; + }); + + @action private onClose() { + this.isSubmitted = false; + this.operatorModeStateService.dismissCreatePRModal(); + } + + +} diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index 3208c3eda69..0c639a8501e 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -75,6 +75,12 @@ export interface CreateListingModalPayload { openCardIds?: string[]; } +export interface CreatePRModalPayload { + realm: string; + listingId: string; + listingName?: string; +} + // Below types form a raw POJO representation of operator mode state. // This state differs from OperatorModeState in that it only contains cards that have been saved (i.e. have an ID). // This is because we don't have a way to serialize a stack configuration of linked cards that have not been saved yet. @@ -150,6 +156,7 @@ export default class OperatorModeStateService extends Service { @tracked profileSettingsOpen = false; @tracked createListingModalPayload?: CreateListingModalPayload; + @tracked createPRModalPayload?: CreatePRModalPayload; @service declare private cardService: CardService; @service declare private codeSemanticsService: CodeSemanticsService; @@ -226,6 +233,14 @@ export default class OperatorModeStateService extends Service { this.createListingModalPayload = undefined; }; + showCreatePRModal = (payload: CreatePRModalPayload) => { + this.createPRModalPayload = payload; + }; + + dismissCreatePRModal = () => { + this.createPRModalPayload = undefined; + }; + setNewFileDropdownOpen = () => { this._state.newFileDropdownOpen = true; this.schedulePersist(); @@ -257,6 +272,7 @@ export default class OperatorModeStateService extends Service { this.moduleInspectorHistory = {}; this.profileSettingsOpen = false; this.createListingModalPayload = undefined; + this.createPRModalPayload = undefined; window.localStorage.removeItem(ModuleInspectorSelections); this.schedulePersist(); } From 17d2994cb9fe227a56497f4c3102524b4ec94a5f Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 24 Mar 2026 22:49:38 +0800 Subject: [PATCH 27/34] fix bot runner test --- packages/bot-runner/tests/bot-runner-test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 04099e847f8..31ba4e18d77 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -196,6 +196,8 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', + submissionRealmUrl: 'http://localhost:4201/submissions/', + submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -282,6 +284,8 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', + submissionRealmUrl: 'http://localhost:4201/submissions/', + submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -424,6 +428,8 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', + submissionRealmUrl: 'http://localhost:4201/submissions/', + submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -491,6 +497,8 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', + submissionRealmUrl: 'http://localhost:4201/submissions/', + submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, From cfc895bf97fe838b4513a96528fee78ef77edb5d Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 10:51:58 +0800 Subject: [PATCH 28/34] update listing submitted title --- packages/host/app/components/operator-mode/create-pr-modal.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/app/components/operator-mode/create-pr-modal.gts b/packages/host/app/components/operator-mode/create-pr-modal.gts index 551be865a18..bd8aa73ba51 100644 --- a/packages/host/app/components/operator-mode/create-pr-modal.gts +++ b/packages/host/app/components/operator-mode/create-pr-modal.gts @@ -78,7 +78,7 @@ export default class CreatePRModal extends Component { Date: Wed, 25 Mar 2026 11:31:08 +0800 Subject: [PATCH 29/34] remove the timeline addon --- packages/bot-runner/lib/command-runner.ts | 10 +++++----- packages/bot-runner/lib/timeline-handler.ts | 7 +------ packages/bot-runner/main.ts | 7 ------- packages/bot-runner/tests/bot-runner-test.ts | 8 -------- .../bot-runner/tests/command-runner-test.ts | 18 +++++++----------- 5 files changed, 13 insertions(+), 37 deletions(-) diff --git a/packages/bot-runner/lib/command-runner.ts b/packages/bot-runner/lib/command-runner.ts index 2cfd378ff01..9c103ae2c29 100644 --- a/packages/bot-runner/lib/command-runner.ts +++ b/packages/bot-runner/lib/command-runner.ts @@ -26,8 +26,7 @@ export class CommandRunner { private createListingPRHandler: CreateListingPRHandler; constructor( - private submissionRealmUrl: string, - private submissionRealmUsername: string, + private submissionBotUserId: string, private dbAdapter: DBAdapter, private queuePublisher: QueuePublisher, githubClient: GitHubClient, @@ -191,12 +190,13 @@ export class CommandRunner { submissionCardUrl: string; prResult: CreatedListingPRResult; }): Promise { + let submissionRealm = new URL('/submissions/', realmURL).href; let prCardResult = await this.enqueueRunCommand({ - runAs: this.submissionRealmUsername, - realmURL: this.submissionRealmUrl, + runAs: this.submissionBotUserId, + realmURL: submissionRealm, command: CREATE_PR_CARD_COMMAND, commandInput: { - realm: this.submissionRealmUrl, + realm: submissionRealm, prNumber: prResult.prNumber, prUrl: prResult.prUrl, prTitle: prResult.prTitle, diff --git a/packages/bot-runner/lib/timeline-handler.ts b/packages/bot-runner/lib/timeline-handler.ts index 5aaf0fa4460..5be51a16ade 100644 --- a/packages/bot-runner/lib/timeline-handler.ts +++ b/packages/bot-runner/lib/timeline-handler.ts @@ -19,8 +19,6 @@ export interface BotRegistration { export interface TimelineHandlerOptions { authUserId: string; - submissionRealmUrl: string; - submissionRealmUsername: string; dbAdapter: DBAdapter; queuePublisher: QueuePublisher; githubClient: GitHubClient; @@ -29,16 +27,13 @@ export interface TimelineHandlerOptions { export function onTimelineEvent({ authUserId, - submissionRealmUrl, - submissionRealmUsername, dbAdapter, queuePublisher, githubClient, startTime, }: TimelineHandlerOptions) { let commandRunner = new CommandRunner( - submissionRealmUrl, - submissionRealmUsername, + authUserId, dbAdapter, queuePublisher, githubClient, diff --git a/packages/bot-runner/main.ts b/packages/bot-runner/main.ts index 5f74c0bcedb..bb2fe0e6244 100644 --- a/packages/bot-runner/main.ts +++ b/packages/bot-runner/main.ts @@ -15,11 +15,6 @@ const matrixUrl = process.env.MATRIX_URL || 'http://localhost:8008'; const submissionBotUsername = process.env.SUBMISSION_BOT_USERNAME || 'submissionbot'; const botPassword = process.env.SUBMISSION_BOT_PASSWORD || 'password'; -const realmServerUrl = process.env.REALM_SERVER_URL || 'http://localhost:4201'; -const submissionRealmUrl = - process.env.SUBMISSION_REALM_URL || `${realmServerUrl}/submissions/`; -const submissionRealmUsername = - process.env.SUBMISSION_REALM_USERNAME || 'submission_realm'; (async () => { let client = createClient({ @@ -68,8 +63,6 @@ const submissionRealmUsername = let handleTimelineEvent = onTimelineEvent({ authUserId: auth.user_id, - submissionRealmUrl, - submissionRealmUsername, dbAdapter, queuePublisher, githubClient, diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 31ba4e18d77..04099e847f8 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -196,8 +196,6 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', - submissionRealmUrl: 'http://localhost:4201/submissions/', - submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -284,8 +282,6 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', - submissionRealmUrl: 'http://localhost:4201/submissions/', - submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -428,8 +424,6 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', - submissionRealmUrl: 'http://localhost:4201/submissions/', - submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, @@ -497,8 +491,6 @@ module('timeline handler', () => { let handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', - submissionRealmUrl: 'http://localhost:4201/submissions/', - submissionRealmUsername: '@submissionbot:localhost', dbAdapter, queuePublisher, githubClient, diff --git a/packages/bot-runner/tests/command-runner-test.ts b/packages/bot-runner/tests/command-runner-test.ts index ae9b39ac463..8d1b74c47c6 100644 --- a/packages/bot-runner/tests/command-runner-test.ts +++ b/packages/bot-runner/tests/command-runner-test.ts @@ -10,7 +10,7 @@ import type { GitHubClient } from '../lib/github'; import { CommandRunner } from '../lib/command-runner'; const SUBMISSION_REALM_URL = 'http://localhost:4201/submissions/'; -const SUBMISSION_REALM_USERNAME = 'submission_realm'; +const SUBMISSION_BOT_USER_ID = '@submissionbot:localhost'; module('command runner', () => { test('enqueues run-command job for matching trigger', async (assert) => { @@ -68,8 +68,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - SUBMISSION_REALM_URL, - SUBMISSION_REALM_USERNAME, + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -207,8 +206,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - SUBMISSION_REALM_URL, - SUBMISSION_REALM_USERNAME, + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -240,8 +238,8 @@ module('command runner', () => { (publishedJobs[1] as { args: Record }).args, { realmURL: SUBMISSION_REALM_URL, - realmUsername: SUBMISSION_REALM_USERNAME, - runAs: SUBMISSION_REALM_USERNAME, + realmUsername: SUBMISSION_BOT_USER_ID, + runAs: SUBMISSION_BOT_USER_ID, command: '@cardstack/catalog/commands/create-pr-card/default', commandInput: { realm: SUBMISSION_REALM_URL, @@ -356,8 +354,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - SUBMISSION_REALM_URL, - SUBMISSION_REALM_USERNAME, + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, @@ -454,8 +451,7 @@ module('command runner', () => { } as DBAdapter; let commandRunner = new CommandRunner( - SUBMISSION_REALM_URL, - SUBMISSION_REALM_USERNAME, + SUBMISSION_BOT_USER_ID, dbAdapter, queuePublisher, githubClient, From 676d9d3da25d95d67963882baba328aa8f6ec00a Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 11:31:29 +0800 Subject: [PATCH 30/34] remove linked theme process --- packages/catalog-realm/commands/create-pr-card.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/catalog-realm/commands/create-pr-card.ts b/packages/catalog-realm/commands/create-pr-card.ts index 023c96af0fb..a87f9ef1e5e 100644 --- a/packages/catalog-realm/commands/create-pr-card.ts +++ b/packages/catalog-realm/commands/create-pr-card.ts @@ -44,7 +44,6 @@ export default class CreatePrCardCommand extends Command< prSummary, submittedBy, } = input; - let catalogRealmUrl = new RealmPaths(new URL('..', import.meta.url)).url; let card = new PrCard({ prNumber, @@ -56,19 +55,6 @@ export default class CreatePrCardCommand extends Command< submittedAt: new Date(), }); - // Link the GitHub PR brand guide theme from the catalog realm - let themeCardId = `${catalogRealmUrl}${GITHUB_PR_THEME_PATH}`; - try { - let theme = await new GetCardCommand(this.commandContext).execute({ - cardId: themeCardId, - }); - if (theme) { - card.cardInfo.theme = theme as Theme; - } - } catch { - // Theme is optional — don't block PR card creation if it can't be fetched - } - // Save the PR card to the submission realm let savedCard = (await new SaveCardCommand(this.commandContext).execute({ card, From 94a017df9e8de2e26b0fbb2894cd4e1323bbf6d3 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 11:36:48 +0800 Subject: [PATCH 31/34] add prnumber on isolated --- .../components/card/isolated-template.gts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts index 91f4e92fc7c..1e0db7464df 100644 --- a/packages/catalog-realm/submission-card/components/card/isolated-template.gts +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -47,6 +47,10 @@ export class IsolatedTemplate extends Component { return this.args.model.roomId; } + get prNumber() { + return this.args.model.prCard?.prNumber; + } + get listingImage() { return this.args.model.listing?.images?.[0]; } @@ -166,7 +170,12 @@ export class IsolatedTemplate extends Component { aria-label='View PR card' {{on 'click' this.openPrCard}} > - View PR + + {{#if this.prNumber}} + View PR #{{this.prNumber}} + {{else}} + View PR + {{/if}} {{/if}}
@@ -453,4 +462,3 @@ export class IsolatedTemplate extends Component { } - From addf8052162e57edf4907d33f6b558aef8e9fabf Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 12:24:31 +0800 Subject: [PATCH 32/34] fix lint --- packages/catalog-realm/commands/create-pr-card.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/catalog-realm/commands/create-pr-card.ts b/packages/catalog-realm/commands/create-pr-card.ts index a87f9ef1e5e..996b0a1cbdc 100644 --- a/packages/catalog-realm/commands/create-pr-card.ts +++ b/packages/catalog-realm/commands/create-pr-card.ts @@ -1,19 +1,11 @@ -import { Command, RealmPaths } from '@cardstack/runtime-common'; -import { - CardDef, - field, - contains, - type Theme, -} from 'https://cardstack.com/base/card-api'; +import { Command } from '@cardstack/runtime-common'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; import MarkdownField from 'https://cardstack.com/base/markdown'; import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; -import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; import { PrCard } from '../pr-card/pr-card'; -const GITHUB_PR_THEME_PATH = 'Theme/github-pr-brand-guide'; - class CreatePrCardInput extends CardDef { @field realm = contains(StringField); @field prNumber = contains(NumberField); From 3c995223ad12e9b4827eb4488266d62f9437f15c Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 12:26:41 +0800 Subject: [PATCH 33/34] add create pr-modal test --- .../commands/open-create-pr-modal-test.gts | 110 ++++++++++++++++++ .../components/create-pr-modal-test.gts | 76 ++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/host/tests/integration/commands/open-create-pr-modal-test.gts create mode 100644 packages/host/tests/integration/components/create-pr-modal-test.gts diff --git a/packages/host/tests/integration/commands/open-create-pr-modal-test.gts b/packages/host/tests/integration/commands/open-create-pr-modal-test.gts new file mode 100644 index 00000000000..a85e2b2bb9a --- /dev/null +++ b/packages/host/tests/integration/commands/open-create-pr-modal-test.gts @@ -0,0 +1,110 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import OpenCreatePRModalCommand from '@cardstack/host/commands/open-create-pr-modal'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, + setupRealmCacheTeardown, + withCachedRealmSetup, +} from '../../helpers'; +import { setupBaseRealm } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +module('Integration | commands | open-create-pr-modal', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + setupRealmCacheTeardown(hooks); + + hooks.beforeEach(async function () { + await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }), + ); + }); + + test('stores modal payload in operator mode state', async function (assert) { + let commandService = getService('command-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + + let command = new OpenCreatePRModalCommand( + commandService.commandContext, + ); + + await command.execute({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + } as never); + + assert.deepEqual(operatorModeStateService.createPRModalPayload, { + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + }); + }); + + test('stores modal payload without listingName', async function (assert) { + let commandService = getService('command-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + + let command = new OpenCreatePRModalCommand( + commandService.commandContext, + ); + + await command.execute({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + } as never); + + let payload = operatorModeStateService.createPRModalPayload; + assert.strictEqual(payload?.realm, testRealmURL); + assert.strictEqual(payload?.listingId, `${testRealmURL}Listing/1`); + assert.strictEqual( + payload?.listingName, + undefined, + 'listingName is undefined when not provided', + ); + }); + + test('dismissCreatePRModal clears the payload', async function (assert) { + let commandService = getService('command-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + + let command = new OpenCreatePRModalCommand( + commandService.commandContext, + ); + + await command.execute({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + } as never); + + assert.ok( + operatorModeStateService.createPRModalPayload, + 'payload is set after execute', + ); + + operatorModeStateService.dismissCreatePRModal(); + + assert.strictEqual( + operatorModeStateService.createPRModalPayload, + undefined, + 'payload is cleared after dismissCreatePRModal', + ); + }); +}); diff --git a/packages/host/tests/integration/components/create-pr-modal-test.gts b/packages/host/tests/integration/components/create-pr-modal-test.gts new file mode 100644 index 00000000000..fce993859e8 --- /dev/null +++ b/packages/host/tests/integration/components/create-pr-modal-test.gts @@ -0,0 +1,76 @@ +import { waitFor } from '@ember/test-helpers'; +import GlimmerComponent from '@glimmer/component'; + +import { module, test } from 'qunit'; + +import OperatorMode from '@cardstack/host/components/operator-mode/container'; + +import { testRealmURL } from '../../helpers'; +import { renderComponent } from '../../helpers/render-component'; + +import { setupOperatorModeTests } from './operator-mode/setup'; + +module('Integration | components | create-pr-modal', function (hooks) { + let ctx = setupOperatorModeTests(hooks); + + let noop = () => {}; + + test('modal renders when payload is set', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreatePRModal({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + }); + + await waitFor('[data-test-create-pr-modal]'); + + assert.dom('[data-test-create-pr-modal]').exists(); + assert + .dom('[data-test-create-pr-modal]') + .includesText('Make a PR'); + }); + + test('shows listing name in modal', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreatePRModal({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + }); + + await waitFor('[data-test-create-pr-modal]'); + + assert + .dom('[data-test-create-pr-listing-name]') + .includesText('My Listing'); + }); + + test('shows realm info in modal', async function (assert) { + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + ctx.operatorModeStateService.showCreatePRModal({ + realm: testRealmURL, + listingId: `${testRealmURL}Listing/1`, + listingName: 'My Listing', + }); + + await waitFor('[data-test-create-pr-modal]'); + + assert.dom('[data-test-create-pr-realm]').exists(); + }); +}); From 57a0501bee29d6dbca538430c538aa83b981818d Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 25 Mar 2026 12:41:45 +0800 Subject: [PATCH 34/34] fix lint --- .../commands/open-create-pr-modal-test.gts | 12 +++--------- .../integration/components/create-pr-modal-test.gts | 8 ++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/host/tests/integration/commands/open-create-pr-modal-test.gts b/packages/host/tests/integration/commands/open-create-pr-modal-test.gts index a85e2b2bb9a..b75609ba4af 100644 --- a/packages/host/tests/integration/commands/open-create-pr-modal-test.gts +++ b/packages/host/tests/integration/commands/open-create-pr-modal-test.gts @@ -40,9 +40,7 @@ module('Integration | commands | open-create-pr-modal', function (hooks) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); - let command = new OpenCreatePRModalCommand( - commandService.commandContext, - ); + let command = new OpenCreatePRModalCommand(commandService.commandContext); await command.execute({ realm: testRealmURL, @@ -61,9 +59,7 @@ module('Integration | commands | open-create-pr-modal', function (hooks) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); - let command = new OpenCreatePRModalCommand( - commandService.commandContext, - ); + let command = new OpenCreatePRModalCommand(commandService.commandContext); await command.execute({ realm: testRealmURL, @@ -84,9 +80,7 @@ module('Integration | commands | open-create-pr-modal', function (hooks) { let commandService = getService('command-service'); let operatorModeStateService = getService('operator-mode-state-service'); - let command = new OpenCreatePRModalCommand( - commandService.commandContext, - ); + let command = new OpenCreatePRModalCommand(commandService.commandContext); await command.execute({ realm: testRealmURL, diff --git a/packages/host/tests/integration/components/create-pr-modal-test.gts b/packages/host/tests/integration/components/create-pr-modal-test.gts index fce993859e8..96776272766 100644 --- a/packages/host/tests/integration/components/create-pr-modal-test.gts +++ b/packages/host/tests/integration/components/create-pr-modal-test.gts @@ -31,9 +31,7 @@ module('Integration | components | create-pr-modal', function (hooks) { await waitFor('[data-test-create-pr-modal]'); assert.dom('[data-test-create-pr-modal]').exists(); - assert - .dom('[data-test-create-pr-modal]') - .includesText('Make a PR'); + assert.dom('[data-test-create-pr-modal]').includesText('Make a PR'); }); test('shows listing name in modal', async function (assert) { @@ -51,9 +49,7 @@ module('Integration | components | create-pr-modal', function (hooks) { await waitFor('[data-test-create-pr-modal]'); - assert - .dom('[data-test-create-pr-listing-name]') - .includesText('My Listing'); + assert.dom('[data-test-create-pr-listing-name]').includesText('My Listing'); }); test('shows realm info in modal', async function (assert) {