From 91c21b465184c1d649b1d902ac96e41e1e15e317 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 19 Jun 2026 10:04:48 +0700 Subject: [PATCH 1/4] Add MiniCardChooser component Standalone, inline card picker for side-by-side layouts. Wraps the existing SearchPanel so the in-flight v2 migration applies automatically when that PR lands; reuses RecentCardsService and the recents section already inside SearchContent. - @onSelect(url) / @onCancel callbacks; no global singleton. - 100% width/height of parent; layout owned by the host container. - Realm/type filter chips suppressed via scoped CSS in the mini variant. - Freestyle usage entry + integration tests that mount in isolation via a context-providing test driver. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cs-11672-mini-card-chooser-plan.md | 64 +++++ .../components/mini-card-chooser/index.gts | 94 +++++++ .../components/mini-card-chooser/usage.gts | 76 ++++++ .../host/app/templates/host-freestyle.gts | 2 + .../components/mini-card-chooser-test.gts | 236 ++++++++++++++++++ 5 files changed, 472 insertions(+) create mode 100644 docs/cs-11672-mini-card-chooser-plan.md create mode 100644 packages/host/app/components/mini-card-chooser/index.gts create mode 100644 packages/host/app/components/mini-card-chooser/usage.gts create mode 100644 packages/host/tests/integration/components/mini-card-chooser-test.gts diff --git a/docs/cs-11672-mini-card-chooser-plan.md b/docs/cs-11672-mini-card-chooser-plan.md new file mode 100644 index 0000000000..45d888ff57 --- /dev/null +++ b/docs/cs-11672-mini-card-chooser-plan.md @@ -0,0 +1,64 @@ +# CS-11672 — Mini card chooser + +Linear: https://linear.app/cardstack/issue/CS-11672/mini-card-chooser +First primitive in the Markdown Editing UI sequence. Composed into the combined chooser modal in ticket 5. + +## Goal + +A standalone `` host component sized for a side-by-side editor layout — smaller and more embeddable than the existing full-screen `card-chooser/modal.gts`. Reuses `` so the in-flight v2 `` migration applies automatically when its PR lands. + +## Decisions + +- **Reuse ``** + its yielded `Bar` + `Content`. Do not touch result rendering — a separate PR migrates that to v2 ``. +- **Inline component contract**: `@onSelect(url)` and `@onCancel`. No global singleton, no `Deferred`. Parent container owns mounting and dismissal. +- **Recents**: reuse the existing `RecentCardsService` and the recents section already inside `SearchContent`. The ticket's "per workspace" wording is misleading; per-workspace scoping is out of scope here. +- **Sizing**: `width: 100%; height: 100%` of the parent. No baked-in pixel dimensions. +- **No chrome**: no Cancel/Close button in the primitive. `@onCancel` is declared for parity with later compositions but not rendered. +- **Hide chips**: hide the realm-filter and type-filter chips (`SearchBar`'s `.search-sheet__search-bar-picker` and `.search-sheet__search-bar-separator`) via scoped CSS in MiniCardChooser. SearchBar itself stays untouched. +- **Independent mount**: integration test via `renderComponent` + a tiny context-provider driver (pattern from `card-context-search-results-test.gts`). Freestyle `usage.gts` entry registered next to `SearchSheetUsage` in `host-freestyle.gts`. + +## Files + +**Create** + +- `packages/host/app/components/mini-card-chooser/index.gts` +- `packages/host/app/components/mini-card-chooser/usage.gts` +- `packages/host/tests/integration/components/mini-card-chooser-test.gts` + +**Modify** + +- `packages/host/app/templates/host-freestyle.gts` — add import + entry in `usageComponents`. + +## Component shape + +```ts +interface Signature { + Element: HTMLDivElement; + Args: { + searchKey?: string; + baseFilter?: Filter; + initialSelectedRealms?: URL[]; + initialSelectedTypes?: ResolvedCodeRef[]; + lockSelectedRealms?: boolean; + onSelect: (url: string) => void; + onCancel?: () => void; + }; +} +``` + +Wraps ``, uses the yielded `Bar` for input (with `@onInput` updating a tracked local searchKey) and yielded `Content` with `@isCompact={{false}}`. `handleSelect` narrows to string and forwards to `@onSelect` (Mini variant never produces `NewCardArgs`). + +## Verification + +1. `pnpm tsc` + `pnpm lint` from `packages/host`. +2. `pnpm test --filter mini-card-chooser` (host integration suite). +3. `mise exec -- pnpm -C packages/host start` + `mise run dev` in two terminals; open `https://localhost:4200/`, navigate to host freestyle, find `MiniCardChooser`, exercise the usage example. +4. Sanity-check the full chooser flow elsewhere (operator-mode "Choose a Card") still works. + +## Out of scope + +v2 `` migration, per-workspace recents, preview pane, format controls, combined modal composition, "Create New" affordance, multi-select. + +## Cleanup + +Delete this plan doc before merging the PR (`[feedback_plan_doc_not_in_merged_branch]`). diff --git a/packages/host/app/components/mini-card-chooser/index.gts b/packages/host/app/components/mini-card-chooser/index.gts new file mode 100644 index 0000000000..45950bf2bb --- /dev/null +++ b/packages/host/app/components/mini-card-chooser/index.gts @@ -0,0 +1,94 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import type { Filter, ResolvedCodeRef } from '@cardstack/runtime-common'; + +import SearchPanel from '@cardstack/host/components/card-search/panel'; +import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; + +interface Signature { + Element: HTMLDivElement; + Args: { + searchKey?: string; + baseFilter?: Filter; + initialSelectedRealms?: URL[]; + initialSelectedTypes?: ResolvedCodeRef[]; + lockSelectedRealms?: boolean; + onSelect: (url: string) => void; + onCancel?: () => void; + }; +} + +function normalizeCardUrl(url: string): string { + return url.replace(/\.json$/, ''); +} + +export default class MiniCardChooser extends Component { + @tracked private searchKey: string = this.args.searchKey ?? ''; + + @action + private setSearchKey(value: string) { + this.searchKey = value; + } + + @action + private handleSelect(selection: string | NewCardArgs) { + if (typeof selection !== 'string') { + return; + } + this.args.onSelect(normalizeCardUrl(selection)); + } + + +} diff --git a/packages/host/app/components/mini-card-chooser/usage.gts b/packages/host/app/components/mini-card-chooser/usage.gts new file mode 100644 index 0000000000..0732300abf --- /dev/null +++ b/packages/host/app/components/mini-card-chooser/usage.gts @@ -0,0 +1,76 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import MiniCardChooser from './index'; + +export default class MiniCardChooserUsage extends Component { + @tracked selectedUrl: string | undefined; + + @action onSelect(url: string) { + this.selectedUrl = url; + } + + @action onCancel() { + this.selectedUrl = undefined; + } + + +} diff --git a/packages/host/app/templates/host-freestyle.gts b/packages/host/app/templates/host-freestyle.gts index d73bf356ed..1442a964f6 100644 --- a/packages/host/app/templates/host-freestyle.gts +++ b/packages/host/app/templates/host-freestyle.gts @@ -23,6 +23,7 @@ import AiAssistantFocusPillUsage from '@cardstack/host/components/ai-assistant/f import AiAssistantMessageUsage from '@cardstack/host/components/ai-assistant/message/usage'; import AiAssistantSkillMenuUsage from '@cardstack/host/components/ai-assistant/skill-menu/usage'; import CardChooserModal from '@cardstack/host/components/card-chooser/modal'; +import MiniCardChooserUsage from '@cardstack/host/components/mini-card-chooser/usage'; import PillMenuUsage from '@cardstack/host/components/pill-menu/usage'; import SearchSheetUsage from '@cardstack/host/components/search-sheet/usage'; @@ -74,6 +75,7 @@ class HostFreestyleComponent extends Component { ['AiAssistant::Message', AiAssistantMessageUsage], ['AiAssistant::PillMenu', PillMenuUsage], ['AiAssistant::SkillMenu', AiAssistantSkillMenuUsage], + ['MiniCardChooser', MiniCardChooserUsage], ['SearchSheet', SearchSheetUsage], ].map(([name, c]) => { return { diff --git a/packages/host/tests/integration/components/mini-card-chooser-test.gts b/packages/host/tests/integration/components/mini-card-chooser-test.gts new file mode 100644 index 0000000000..6c3160662d --- /dev/null +++ b/packages/host/tests/integration/components/mini-card-chooser-test.gts @@ -0,0 +1,236 @@ +import { + type RenderingTestContext, + click, + fillIn, + render, + waitFor, + waitUntil, +} from '@ember/test-helpers'; + +import GlimmerComponent from '@glimmer/component'; + +import { getService } from '@universal-ember/test-support'; +import { provide } from 'ember-provide-consume-context'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type getCard as GetCardType, + CardContextName, + GetCardContextName, + GetCardCollectionContextName, + GetCardsContextName, +} from '@cardstack/runtime-common'; + +import MiniCardChooser from '@cardstack/host/components/mini-card-chooser'; +import { getCardCollection } from '@cardstack/host/resources/card-collection'; +import { getCard } from '@cardstack/host/resources/card-resource'; +import type RecentCardsService from '@cardstack/host/services/recent-cards-service'; +import type StoreService from '@cardstack/host/services/store'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, +} from '../../helpers'; +import { + CardDef, + StringField, + contains, + field, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; + +import { setupRenderingTest } from '../../helpers/setup'; + +// Provides the contexts SearchContent reads via @consume so the +// chooser can render in isolation, without OperatorMode. +class HostContextProvider extends GlimmerComponent<{ + Blocks: { default: [] }; +}> { + @provide(GetCardContextName) + get getCardFn() { + return getCard as unknown as GetCardType; + } + + @provide(GetCardsContextName) + get getCardsFn() { + let store = getService('store') as StoreService; + return store.getSearchResource.bind(store); + } + + @provide(GetCardCollectionContextName) + get getCardCollectionFn() { + return getCardCollection; + } + + @provide(CardContextName) + get cardContext() { + return {}; + } + + +} + +module('Integration | mini-card-chooser', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + const noop = () => {}; + + hooks.beforeEach(async function (this: RenderingTestContext) { + class Book extends CardDef { + static displayName = 'Book'; + @field title = contains(StringField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'book.gts': { Book }, + 'books/mango.json': new Book({ title: 'Mango' }), + 'books/vincent.json': new Book({ title: 'Vincent' }), + }, + }); + await getService('realm').login(testRealmURL); + }); + + test('renders the search bar and result sections from the active realm', async function (assert) { + const selections: string[] = []; + const onSelect = (url: string) => selections.push(url); + + await render( + , + ); + + await waitFor('[data-test-mini-card-chooser] [data-test-search-field]'); + await waitFor( + `[data-test-mini-card-chooser] [data-test-realm="Unnamed Workspace"]`, + { timeout: 5000 }, + ).catch(() => {}); + + assert + .dom('[data-test-mini-card-chooser]') + .exists('the mini chooser mounts in isolation'); + assert + .dom('[data-test-mini-card-chooser] [data-test-search-field]') + .exists('the search input is rendered'); + + // The realm/type chips are deliberately suppressed in the mini variant. + assert + .dom('[data-test-mini-card-chooser] .search-sheet__search-bar-picker') + .doesNotExist('realm/type filter chips are hidden in the mini variant'); + }); + + test('selecting a card invokes onSelect with the canonical URL (no .json suffix)', async function (assert) { + const selections: string[] = []; + const onSelect = (url: string) => selections.push(url); + + await render( + , + ); + + const cardUrl = `${testRealmURL}books/mango`; + await waitFor( + `[data-test-mini-card-chooser] [data-test-item-button="${cardUrl}"]`, + { + timeout: 5000, + }, + ); + + await click( + `[data-test-mini-card-chooser] [data-test-item-button="${cardUrl}"]`, + ); + + await waitUntil(() => selections.length > 0); + assert.deepEqual( + selections, + [cardUrl], + 'onSelect receives the canonical URL exactly once', + ); + }); + + test('typing in the search input narrows the result set', async function (assert) { + await render( + , + ); + + const mango = `${testRealmURL}books/mango`; + const vincent = `${testRealmURL}books/vincent`; + await waitFor( + `[data-test-mini-card-chooser] [data-test-item-button="${mango}"]`, + { + timeout: 5000, + }, + ); + + await fillIn( + '[data-test-mini-card-chooser] [data-test-search-field]', + 'Vincent', + ); + await waitFor( + `[data-test-mini-card-chooser] [data-test-item-button="${vincent}"]`, + { + timeout: 5000, + }, + ); + + assert + .dom(`[data-test-mini-card-chooser] [data-test-item-button="${vincent}"]`) + .exists('the matching card remains visible'); + assert + .dom(`[data-test-mini-card-chooser] [data-test-item-button="${mango}"]`) + .doesNotExist('the non-matching card is filtered out'); + }); + + test('the recents section renders when RecentCardsService has entries', async function (assert) { + let recent = getService('recent-cards-service') as RecentCardsService; + recent.add(`${testRealmURL}books/mango`); + + await render( + , + ); + + await waitFor( + '[data-test-mini-card-chooser] [data-test-search-result-section="recent-cards"], ' + + '[data-test-mini-card-chooser] [data-section-sid="recent-cards"]', + { timeout: 5000 }, + ); + assert.ok( + document.querySelector( + '[data-test-mini-card-chooser] [data-section-sid="recent-cards"], ' + + '[data-test-mini-card-chooser] [data-test-search-result-section="recent-cards"]', + ), + 'the recents section is rendered when recents service has entries', + ); + }); +}); From e032923b797b18d69d387be6bdeaaf921a6e5fd8 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 19 Jun 2026 11:10:01 +0700 Subject: [PATCH 2/4] Fix mini-card-chooser CI: hide pickers via template arg + correct empty-state tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI surfaced two issues in the first push: 1. The scoped-CSS `display: none` rule for the realm/type pickers kept the DOM, so the chip-absence assertion failed. Replace with a `@hidePickers` arg on SearchBar that conditionally renders the pickers and separator; MiniCardChooser passes it through. Ticket 5 can opt in too. 2. Three tests assumed item buttons appear on an empty search key, but SearchContent's sheet-mode `shouldSkipQuery` skips the query when empty — by design (empty state shows Recents only). Reshape the tests around the actual behavior: one mounts and asserts chip absence, one pre-populates Recents and clicks through, one types a search term to drive the realm query. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/card-search/search-bar.gts | 26 ++++--- .../components/mini-card-chooser/index.gts | 5 +- .../components/mini-card-chooser-test.gts | 71 +++++-------------- 3 files changed, 33 insertions(+), 69 deletions(-) diff --git a/packages/host/app/components/card-search/search-bar.gts b/packages/host/app/components/card-search/search-bar.gts index 7b7d8dc4a8..601a1dda2d 100644 --- a/packages/host/app/components/card-search/search-bar.gts +++ b/packages/host/app/components/card-search/search-bar.gts @@ -43,6 +43,7 @@ interface Signature { bottomTreatment?: BoxelInputBottomTreatments; state?: 'none' | 'valid' | 'invalid' | 'loading' | 'initial'; id?: string; + hidePickers?: boolean; }; Blocks: {}; } @@ -66,21 +67,26 @@ export default class SearchBar extends Component { height='18' /> -
- -
- {{#unless @typeFilter.skipTypeFiltering}} + {{#unless @hidePickers}}
-
+ {{#unless @typeFilter.skipTypeFiltering}} +
+ +
+ {{/unless}} + {{/unless}} - {{! template-lint-disable no-invalid-interactive }}
{
@@ -85,10 +86,6 @@ export default class MiniCardChooser extends Component { display: flex; flex-direction: column; } - .mini-card-chooser :deep(.search-sheet__search-bar-picker), - .mini-card-chooser :deep(.search-sheet__search-bar-separator) { - display: none; - } } diff --git a/packages/host/tests/integration/components/mini-card-chooser-test.gts b/packages/host/tests/integration/components/mini-card-chooser-test.gts index 6c3160662d..9eb9552b83 100644 --- a/packages/host/tests/integration/components/mini-card-chooser-test.gts +++ b/packages/host/tests/integration/components/mini-card-chooser-test.gts @@ -108,23 +108,16 @@ module('Integration | mini-card-chooser', function (hooks) { await getService('realm').login(testRealmURL); }); - test('renders the search bar and result sections from the active realm', async function (assert) { - const selections: string[] = []; - const onSelect = (url: string) => selections.push(url); - + test('mounts in isolation with a search input and no filter chips', async function (assert) { await render( , ); await waitFor('[data-test-mini-card-chooser] [data-test-search-field]'); - await waitFor( - `[data-test-mini-card-chooser] [data-test-realm="Unnamed Workspace"]`, - { timeout: 5000 }, - ).catch(() => {}); assert .dom('[data-test-mini-card-chooser]') @@ -132,14 +125,16 @@ module('Integration | mini-card-chooser', function (hooks) { assert .dom('[data-test-mini-card-chooser] [data-test-search-field]') .exists('the search input is rendered'); - // The realm/type chips are deliberately suppressed in the mini variant. assert .dom('[data-test-mini-card-chooser] .search-sheet__search-bar-picker') .doesNotExist('realm/type filter chips are hidden in the mini variant'); }); - test('selecting a card invokes onSelect with the canonical URL (no .json suffix)', async function (assert) { + test('shows recents in the empty state, and selecting one fires onSelect with the canonical URL', async function (assert) { + let recent = getService('recent-cards-service') as RecentCardsService; + recent.add(`${testRealmURL}books/mango`); + const selections: string[] = []; const onSelect = (url: string) => selections.push(url); @@ -153,10 +148,8 @@ module('Integration | mini-card-chooser', function (hooks) { const cardUrl = `${testRealmURL}books/mango`; await waitFor( - `[data-test-mini-card-chooser] [data-test-item-button="${cardUrl}"]`, - { - timeout: 5000, - }, + `[data-test-mini-card-chooser] [data-section-sid="recents"] [data-test-item-button="${cardUrl}"]`, + { timeout: 5000 }, ); await click( @@ -167,11 +160,11 @@ module('Integration | mini-card-chooser', function (hooks) { assert.deepEqual( selections, [cardUrl], - 'onSelect receives the canonical URL exactly once', + 'onSelect receives the canonical URL exactly once (no .json suffix)', ); }); - test('typing in the search input narrows the result set', async function (assert) { + test('typing in the search input renders matching results from the realm', async function (assert) { await render( , ); - const mango = `${testRealmURL}books/mango`; + await waitFor('[data-test-mini-card-chooser] [data-test-search-field]'); + const vincent = `${testRealmURL}books/vincent`; - await waitFor( - `[data-test-mini-card-chooser] [data-test-item-button="${mango}"]`, - { - timeout: 5000, - }, - ); + const mango = `${testRealmURL}books/mango`; await fillIn( '[data-test-mini-card-chooser] [data-test-search-field]', @@ -195,42 +184,14 @@ module('Integration | mini-card-chooser', function (hooks) { ); await waitFor( `[data-test-mini-card-chooser] [data-test-item-button="${vincent}"]`, - { - timeout: 5000, - }, + { timeout: 5000 }, ); assert .dom(`[data-test-mini-card-chooser] [data-test-item-button="${vincent}"]`) - .exists('the matching card remains visible'); + .exists('the matching card surfaces'); assert .dom(`[data-test-mini-card-chooser] [data-test-item-button="${mango}"]`) - .doesNotExist('the non-matching card is filtered out'); - }); - - test('the recents section renders when RecentCardsService has entries', async function (assert) { - let recent = getService('recent-cards-service') as RecentCardsService; - recent.add(`${testRealmURL}books/mango`); - - await render( - , - ); - - await waitFor( - '[data-test-mini-card-chooser] [data-test-search-result-section="recent-cards"], ' + - '[data-test-mini-card-chooser] [data-section-sid="recent-cards"]', - { timeout: 5000 }, - ); - assert.ok( - document.querySelector( - '[data-test-mini-card-chooser] [data-section-sid="recent-cards"], ' + - '[data-test-mini-card-chooser] [data-test-search-result-section="recent-cards"]', - ), - 'the recents section is rendered when recents service has entries', - ); + .doesNotExist('non-matching cards are filtered out'); }); }); From b9e89fee5c54644fb17cee67063d9f126714f402 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 19 Jun 2026 14:15:14 +0700 Subject: [PATCH 3/4] Remove mini-card-chooser plan doc Plan doc is a scratch artifact for the planning phase; the merged branch keeps only code. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cs-11672-mini-card-chooser-plan.md | 64 ------------------------- 1 file changed, 64 deletions(-) delete mode 100644 docs/cs-11672-mini-card-chooser-plan.md diff --git a/docs/cs-11672-mini-card-chooser-plan.md b/docs/cs-11672-mini-card-chooser-plan.md deleted file mode 100644 index 45d888ff57..0000000000 --- a/docs/cs-11672-mini-card-chooser-plan.md +++ /dev/null @@ -1,64 +0,0 @@ -# CS-11672 — Mini card chooser - -Linear: https://linear.app/cardstack/issue/CS-11672/mini-card-chooser -First primitive in the Markdown Editing UI sequence. Composed into the combined chooser modal in ticket 5. - -## Goal - -A standalone `` host component sized for a side-by-side editor layout — smaller and more embeddable than the existing full-screen `card-chooser/modal.gts`. Reuses `` so the in-flight v2 `` migration applies automatically when its PR lands. - -## Decisions - -- **Reuse ``** + its yielded `Bar` + `Content`. Do not touch result rendering — a separate PR migrates that to v2 ``. -- **Inline component contract**: `@onSelect(url)` and `@onCancel`. No global singleton, no `Deferred`. Parent container owns mounting and dismissal. -- **Recents**: reuse the existing `RecentCardsService` and the recents section already inside `SearchContent`. The ticket's "per workspace" wording is misleading; per-workspace scoping is out of scope here. -- **Sizing**: `width: 100%; height: 100%` of the parent. No baked-in pixel dimensions. -- **No chrome**: no Cancel/Close button in the primitive. `@onCancel` is declared for parity with later compositions but not rendered. -- **Hide chips**: hide the realm-filter and type-filter chips (`SearchBar`'s `.search-sheet__search-bar-picker` and `.search-sheet__search-bar-separator`) via scoped CSS in MiniCardChooser. SearchBar itself stays untouched. -- **Independent mount**: integration test via `renderComponent` + a tiny context-provider driver (pattern from `card-context-search-results-test.gts`). Freestyle `usage.gts` entry registered next to `SearchSheetUsage` in `host-freestyle.gts`. - -## Files - -**Create** - -- `packages/host/app/components/mini-card-chooser/index.gts` -- `packages/host/app/components/mini-card-chooser/usage.gts` -- `packages/host/tests/integration/components/mini-card-chooser-test.gts` - -**Modify** - -- `packages/host/app/templates/host-freestyle.gts` — add import + entry in `usageComponents`. - -## Component shape - -```ts -interface Signature { - Element: HTMLDivElement; - Args: { - searchKey?: string; - baseFilter?: Filter; - initialSelectedRealms?: URL[]; - initialSelectedTypes?: ResolvedCodeRef[]; - lockSelectedRealms?: boolean; - onSelect: (url: string) => void; - onCancel?: () => void; - }; -} -``` - -Wraps ``, uses the yielded `Bar` for input (with `@onInput` updating a tracked local searchKey) and yielded `Content` with `@isCompact={{false}}`. `handleSelect` narrows to string and forwards to `@onSelect` (Mini variant never produces `NewCardArgs`). - -## Verification - -1. `pnpm tsc` + `pnpm lint` from `packages/host`. -2. `pnpm test --filter mini-card-chooser` (host integration suite). -3. `mise exec -- pnpm -C packages/host start` + `mise run dev` in two terminals; open `https://localhost:4200/`, navigate to host freestyle, find `MiniCardChooser`, exercise the usage example. -4. Sanity-check the full chooser flow elsewhere (operator-mode "Choose a Card") still works. - -## Out of scope - -v2 `` migration, per-workspace recents, preview pane, format controls, combined modal composition, "Create New" affordance, multi-select. - -## Cleanup - -Delete this plan doc before merging the PR (`[feedback_plan_doc_not_in_merged_branch]`). From aac20c34ab7e51f0b83196874bdf7f3b44e3a7f5 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 19 Jun 2026 19:29:29 +0700 Subject: [PATCH 4/4] Co-locate MiniCardChooser with card-chooser/modal and add mini variant Move mini-card-chooser/ under card-chooser/mini/ so the picker primitives group with the existing chooser modal. Wire a `variant='mini'` knob through SearchContent / SearchResultSection / SearchResultHeader / ItemButton / SearchBar so the chooser renders as single-line rows with teal selection fill + checkmark, hides the view picker and per-section show-only toggle, and keeps the recents count visible. Trim the chooser API to the props the mini surface can actually honor (the realm/type pickers are suppressed), and drop the matching tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mini}/index.gts | 50 +++-- .../mini}/usage.gts | 29 ++- .../app/components/card-search/constants.ts | 5 + .../components/card-search/item-button.gts | 35 +++- .../components/card-search/search-content.gts | 45 ++++- .../card-search/search-result-header.gts | 13 +- .../card-search/search-result-section.gts | 124 ++++++++++-- .../components/card-search/section-header.gts | 3 + .../host/app/templates/host-freestyle.gts | 2 +- .../components/mini-card-chooser-test.gts | 188 ++++++++++++++++-- 10 files changed, 425 insertions(+), 69 deletions(-) rename packages/host/app/components/{mini-card-chooser => card-chooser/mini}/index.gts (55%) rename packages/host/app/components/{mini-card-chooser => card-chooser/mini}/usage.gts (69%) diff --git a/packages/host/app/components/mini-card-chooser/index.gts b/packages/host/app/components/card-chooser/mini/index.gts similarity index 55% rename from packages/host/app/components/mini-card-chooser/index.gts rename to packages/host/app/components/card-chooser/mini/index.gts index 1daf06badd..f40aae5e95 100644 --- a/packages/host/app/components/mini-card-chooser/index.gts +++ b/packages/host/app/components/card-chooser/mini/index.gts @@ -2,30 +2,36 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import type { Filter, ResolvedCodeRef } from '@cardstack/runtime-common'; +import type { Filter } from '@cardstack/runtime-common'; import SearchPanel from '@cardstack/host/components/card-search/panel'; -import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import { + removeFileExtension, + type NewCardArgs, +} from '@cardstack/host/utils/card-search/types'; interface Signature { Element: HTMLDivElement; Args: { - searchKey?: string; + initialSearchKey?: string; baseFilter?: Filter; - initialSelectedRealms?: URL[]; - initialSelectedTypes?: ResolvedCodeRef[]; - lockSelectedRealms?: boolean; onSelect: (url: string) => void; - onCancel?: () => void; + // URL of the currently selected card — the matching row gets the teal + // selection treatment + checkmark. Omit for a chooser that surfaces + // matches but doesn't persist a pinned selection. + selected?: string; }; } -function normalizeCardUrl(url: string): string { - return url.replace(/\.json$/, ''); -} - export default class MiniCardChooser extends Component { - @tracked private searchKey: string = this.args.searchKey ?? ''; + @tracked private searchKey: string = this.args.initialSearchKey ?? ''; + + // SearchContent expects an array (it's the multi-select API plumbing). + // Wrap the single-select `@selected` so the existing isCardSelected check + // in SearchResultSection picks it up without any other changes. + private get selectedCards(): string[] | undefined { + return this.args.selected ? [this.args.selected] : undefined; + } @action private setSearchKey(value: string) { @@ -37,7 +43,10 @@ export default class MiniCardChooser extends Component { if (typeof selection !== 'string') { return; } - this.args.onSelect(normalizeCardUrl(selection)); + let normalized = removeFileExtension(selection); + if (normalized) { + this.args.onSelect(normalized); + } }