From 0dba7ccd298baa452a9a35ccf5a43990277020d9 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 24 Jul 2026 15:43:29 +0700 Subject: [PATCH 1/2] Show loading indicator in embed chooser preview while a pick resolves The right preview pane showed the "nothing selected" placeholder while a picked card/file was still loading, so a slow (cold) load read as "no selection" rather than "loading". Add a loading state (rendered when the load task is running, in place of the empty placeholder), and route an unexpected load rejection to the existing broken-ref visual so the pane can't spin forever. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser/tab-panel.gts | 68 +++++++-- .../markdown-embed-chooser-modal-test.gts | 142 ++++++++++++++++++ 2 files changed, 197 insertions(+), 13 deletions(-) diff --git a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts index 1afe7559530..37e64534de0 100644 --- a/packages/host/app/components/markdown-embed-chooser/tab-panel.gts +++ b/packages/host/app/components/markdown-embed-chooser/tab-panel.gts @@ -5,7 +5,7 @@ import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { restartableTask } from 'ember-concurrency'; +import { didCancel, restartableTask } from 'ember-concurrency'; import { BoxelButton, LoadingIndicator } from '@cardstack/boxel-ui/components'; import type { @@ -158,27 +158,53 @@ export default class MarkdownEmbedChooserTabPanel extends Component { this.selectedUrl = url; this.selectedTarget = undefined; this.selectedError = undefined; - let result = - refType === 'card' - ? await this.store.get(url) - : await this.store.get(url, { type: 'file-meta' }); - if (isCardErrorJSONAPI(result)) { - // Keep `selectedUrl` and leave `selectedTarget` undefined; the pane - // renders the broken-ref visual from `selectedError` instead of the - // resolved embed. - this.selectedError = result; - return; + try { + let result = + refType === 'card' + ? await this.store.get(url) + : await this.store.get(url, { type: 'file-meta' }); + if (isCardErrorJSONAPI(result)) { + // Keep `selectedUrl` and leave `selectedTarget` undefined; the pane + // renders the broken-ref visual from `selectedError` instead of the + // resolved embed. + this.selectedError = result; + return; + } + this.selectedTarget = result as CardDef | FileDef; + } catch (e) { + // A superseding pick cancels this run; let that bubble so the newer + // load owns the state. `store.get` resolves its own errors as + // `CardErrorJSONAPI` (handled above), so reaching here means an + // unexpected throw — surface it as a broken ref rather than leaving + // the pane spinning on its loading state forever. + if (didCancel(e)) { + throw e; + } + this.selectedError = { + id: url, + status: 500, + title: 'Failed to load', + message: e instanceof Error ? e.message : String(e), + } as CardErrorJSONAPI; } - this.selectedTarget = result as CardDef | FileDef; }, ); // The pane mounts once a row is picked and either resolves (selectedTarget) - // or fails (selectedError); the empty placeholder shows only before then. + // or fails (selectedError). Before then the right column shows the loading + // indicator while `isLoading`, falling back to the empty placeholder only + // when nothing is being resolved. private get hasPreview(): boolean { return !!this.selectedTarget || !!this.selectedError; } + // A pick is resolving: its URL is set but neither the instance nor an error + // has landed yet. Drives the loading indicator so a slow (cold) load reads as + // "loading", not as the "nothing selected" placeholder. + private get isLoading(): boolean { + return this.loadTarget.isRunning; + } + // Broken-ref state threaded to the pane. Each is undefined unless the load // failed, so the pane renders the resolved embed on the happy path and the // broken visual only when `selectedError` is set. @@ -331,6 +357,13 @@ export default class MarkdownEmbedChooserTabPanel extends Component { @brokenItemType={{this.brokenItemType}} @errorDoc={{this.brokenErrorDoc}} /> + {{else if this.isLoading}} +
+ +
{{else}}

{ color: var(--boxel-450); text-align: center; } + /* Shown while a picked row's instance is still resolving, in place of the + empty placeholder, so a slow (cold) load doesn't read as "no selection". */ + .markdown-embed-chooser-tab-panel__loading { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding: var(--boxel-sp); + } .markdown-embed-chooser-tab-panel__current { display: flex; flex-direction: column; diff --git a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts index 58db55de9f7..3cf780c466b 100644 --- a/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts +++ b/packages/host/tests/integration/components/markdown-embed-chooser-modal-test.gts @@ -3,6 +3,7 @@ import { click, fillIn, render, + settled, triggerEvent, triggerKeyEvent, waitFor, @@ -574,6 +575,147 @@ module('Integration | markdown-embed-chooser-modal', function (hooks) { ); }); + test('the preview pane shows a loading indicator (not the empty placeholder) while a picked card resolves', async function (assert) { + // Gate the store load for the picked card so the loading window is + // observable. Other loads (search, base realm) pass straight through. + let store = getService('store') as StoreService; + let origGet = store.get.bind(store); + let releaseLoad!: () => void; + let gate = new Promise((resolve) => (releaseLoad = resolve)); + store.get = ((id: string, opts?: unknown) => { + if (id === mango) { + return gate.then(() => origGet(id, opts as never)); + } + return origGet(id, opts as never); + }) as typeof store.get; + + await render( + , + ); + + let svc = getService( + 'markdown-embed-chooser', + ) as MarkdownEmbedChooserService; + let pending = svc.chooseCardOrFile({ defaultTab: 'card' }); + await waitFor('[data-test-markdown-embed-chooser-modal]'); + + await fillIn( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', + 'Mango', + ); + await waitFor( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + { timeout: 5000 }, + ); + + // Fire the click natively rather than via `click()` (which awaits + // settledness and would block on the gated load task) so we can assert the + // in-flight loading state. + ( + document.querySelector( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + ) as HTMLElement + ).click(); + + await waitFor( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + { timeout: 5000 }, + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .exists( + 'the preview pane shows a loading indicator while the pick loads', + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-empty]', + ) + .doesNotExist('the empty placeholder is suppressed during loading'); + + releaseLoad(); + await settled(); + + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview]', + ) + .exists('the resolved card renders its embed once the load completes'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .doesNotExist('the loading indicator clears once the preview is shown'); + + svc.resolve(undefined); + await pending; + }); + + test('a card load that throws shows the broken preview, not a stuck loading/empty state', async function (assert) { + let store = getService('store') as StoreService; + let origGet = store.get.bind(store); + store.get = ((id: string, opts?: unknown) => { + if (id === mango) { + return Promise.reject(new Error('boom')); + } + return origGet(id, opts as never); + }) as typeof store.get; + + await render( + , + ); + + let svc = getService( + 'markdown-embed-chooser', + ) as MarkdownEmbedChooserService; + let pending = svc.chooseCardOrFile({ defaultTab: 'card' }); + await waitFor('[data-test-markdown-embed-chooser-modal]'); + + await fillIn( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', + 'Mango', + ); + await waitFor( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + { timeout: 5000 }, + ); + await click( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + ); + + await waitFor( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-broken-link-template]', + { timeout: 5000 }, + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-broken-link-template]', + ) + .exists('a thrown load surfaces the broken-ref visual'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-empty]', + ) + .doesNotExist('the empty placeholder is suppressed for a failed load'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .doesNotExist('the pane does not stay stuck on the loading indicator'); + + svc.resolve(undefined); + await pending; + }); + test('a picked card serializes a document-relative ref when a base URL is supplied', async function (assert) { await render(