Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/host/app/components/markdown-embed-chooser/pane.gts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ interface Signature {
// Overrides the dynamic "Insert as …" CTA label. Used in edit mode to
// show 'Done' (clean) or 'Accept' (dirty) per the design spec.
ctaLabelOverride?: string;
// True while the picked target is still resolving. The viewport renders a
// size-matched loading placeholder and the CTA is held disabled until the
// instance (or an error) lands.
loading?: boolean;
};
}

Expand Down Expand Up @@ -124,6 +128,9 @@ export default class MarkdownEmbedPreviewPane extends Component<Signature> {
// for editing seeds the Custom category with no dimensions but is a valid,
// supported form, so its unchanged (non-dirty) state keeps the CTA enabled.
private get ctaDisabled(): boolean {
if (this.args.loading) {
return true;
}
return !this.args.selection.hasValidSize && this.args.selection.isDirty;
}

Expand Down Expand Up @@ -170,6 +177,7 @@ export default class MarkdownEmbedPreviewPane extends Component<Signature> {
<div class='markdown-embed-preview-pane__viewport'>
<MarkdownEmbedPreview
@target={{@target}}
@loading={{@loading}}
@brokenUrl={{@brokenUrl}}
@brokenDisplayName={{@brokenDisplayName}}
@brokenItemType={{@brokenItemType}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { htmlSafe } from '@ember/template';

import Component from '@glimmer/component';

import { BrokenLinkTemplate } from '@cardstack/boxel-ui/components';
import {
BrokenLinkTemplate,
LoadingIndicator,
} from '@cardstack/boxel-ui/components';
import type {
BrokenLinkErrorDoc,
BrokenLinkItemType,
Expand All @@ -30,7 +33,10 @@ const EMPTY_ERROR_DOC: BrokenLinkErrorDoc = Object.freeze({});
interface EmbedSignature {
Element: HTMLElement;
Args: {
target: CardDef | FileDef;
// The resolved instance to render. Absent while `loading` — the box then
// holds a spinner in place of the card body, keeping the same footprint.
target?: CardDef | FileDef;
loading?: boolean;
format: Format;
kind: 'inline' | 'block';
sizeStyle?: ReturnType<typeof htmlSafe>;
Expand All @@ -52,33 +58,45 @@ const Embed: TOC<EmbedSignature> = <template>
"markdown-embed-preview--inline-embed"
}}
{{if @sizeStyle "markdown-embed-preview--fitted"}}
{{if (not (eq @format "atom")) "markdown-embed-preview--card-frame"}}'
{{if (not (eq @format "atom")) "markdown-embed-preview--card-frame"}}
{{if @loading "is-loading"}}'
style={{@sizeStyle}}
data-test-markdown-embed-preview
data-test-markdown-embed-preview-format={{@format}}
data-test-markdown-embed-preview={{if (not @loading) 'true'}}
data-test-markdown-embed-preview-format={{if (not @loading) @format}}
data-test-markdown-embed-preview-loading={{if @loading 'true'}}
...attributes
>
<CardRenderer
@card={{@target}}
@format={{@format}}
@displayContainer={{false}}
/>
{{#if @target}}
<CardRenderer
@card={{@target}}
@format={{@format}}
@displayContainer={{false}}
/>
{{else if @loading}}
<LoadingIndicator class='markdown-embed-preview__spinner' />
{{/if}}
</span>
{{else}}
<div
class='markdown-embed-preview markdown-embed-preview--block
{{if @sizeStyle "markdown-embed-preview--fitted"}}
{{if (not (eq @format "atom")) "markdown-embed-preview--card-frame"}}'
{{if (not (eq @format "atom")) "markdown-embed-preview--card-frame"}}
{{if @loading "is-loading"}}'
style={{@sizeStyle}}
data-test-markdown-embed-preview
data-test-markdown-embed-preview-format={{@format}}
data-test-markdown-embed-preview={{if (not @loading) 'true'}}
data-test-markdown-embed-preview-format={{if (not @loading) @format}}
data-test-markdown-embed-preview-loading={{if @loading 'true'}}
...attributes
>
<CardRenderer
@card={{@target}}
@format={{@format}}
@displayContainer={{false}}
/>
{{#if @target}}
<CardRenderer
@card={{@target}}
@format={{@format}}
@displayContainer={{false}}
/>
{{else if @loading}}
<LoadingIndicator class='markdown-embed-preview__spinner' />
{{/if}}
</div>
{{/if}}
<style scoped>
Expand Down Expand Up @@ -109,6 +127,24 @@ const Embed: TOC<EmbedSignature> = <template>
background-color: var(--boxel-light);
overflow: hidden;
}
/* Loading placeholder: keep the resolved embed's footprint (size + frame)
but center a small spinner where the card body will land. Force a flex
box so the spinner centers in the block / inline-embed variants (the atom
variant is already inline-flex). */
.markdown-embed-preview.is-loading {
align-items: center;
justify-content: center;
}
.markdown-embed-preview--block.is-loading {
display: flex;
}
.markdown-embed-preview--inline-embed.is-loading {
display: inline-flex;
}
/* Small enough to sit inside an atom pill, unobtrusive in larger frames. */
.markdown-embed-preview__spinner {
--boxel-loading-indicator-size: 1.25rem;
}
Comment on lines +144 to +147

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Non-blocking cleanup. This override is a no-op: LoadingIndicator sizes itself from var(--boxel-loading-indicator-size, var(--boxel-icon-sm)), and --boxel-icon-sm is defined as 1.25rem in boxel-ui's variables.css — so setting --boxel-loading-indicator-size: 1.25rem here just re-states the default. The rendered spinner is the same size with or without this rule (and the class='markdown-embed-preview__spinner' that carries it). Either drop both, or — if a distinct size was intended — pick a value that actually differs from the default (compare operator-mode/workspace-chooser/workspace.gts, which sets 2rem for a genuine override). The comment (“small enough to sit inside an atom pill”) also reads as if it shrinks the spinner, which it doesn't.

</style>
</template>;

Expand Down Expand Up @@ -194,6 +230,10 @@ interface Signature {
// nothing. Absent when the ref failed to resolve; the broken-ref args
// below then drive the render.
target?: CardDef | FileDef;
// True while the caller is still resolving the target. Renders a
// size-matched loading placeholder (the card's footprint with a spinner)
// in the embed's slot until `target` or a broken-ref state arrives.
loading?: boolean;
// Broken-ref render: when `brokenUrl` is present (and `target` is not),
// render `BrokenLinkTemplate` instead of the embed. The same warning box +
// reveal overlay the base `linksTo` broken UI shows, format-aware so the
Expand Down Expand Up @@ -327,6 +367,13 @@ export default class MarkdownEmbedPreview extends Component<Signature> {
@kind={{this.kind}}
@sizeStyle={{this.sizeStyle}}
/>
{{else if @loading}}
<Embed
@loading={{true}}
@format={{this.renderFormat}}
@kind={{this.kind}}
@sizeStyle={{this.sizeStyle}}
/>
{{else if @brokenUrl}}
<BrokenEmbed
@brokenUrl={{@brokenUrl}}
Expand Down Expand Up @@ -365,6 +412,14 @@ export default class MarkdownEmbedPreview extends Component<Signature> {
@sizeStyle={{this.sizeStyle}}
...attributes
/>
{{else if @loading}}
<Embed
@loading={{true}}
@format={{this.renderFormat}}
@kind={{this.kind}}
@sizeStyle={{this.sizeStyle}}
...attributes
/>
{{else if @brokenUrl}}
<BrokenEmbed
@brokenUrl={{@brokenUrl}}
Expand Down
63 changes: 49 additions & 14 deletions packages/host/app/components/markdown-embed-chooser/tab-panel.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -158,27 +158,61 @@ export default class MarkdownEmbedChooserTabPanel extends Component<Signature> {
this.selectedUrl = url;
this.selectedTarget = undefined;
this.selectedError = undefined;
let result =
refType === 'card'
? await this.store.get(url)
: await this.store.get<FileDef>(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<FileDef>(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 placeholder so a slow (cold) load reads
// as "loading", not as the "nothing selected" placeholder.
private get isLoading(): boolean {
return this.loadTarget.isRunning;
}

// The pane mounts while a pick is loading, resolved, or errored — so the
// format/size controls and the size-matched preview stay put across the
// load (no jump to a bare spinner). The empty placeholder shows only before
// anything is picked.
private get showPane(): boolean {
return this.hasPreview || this.isLoading;
}

// 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.
Expand Down Expand Up @@ -317,9 +351,10 @@ export default class MarkdownEmbedChooserTabPanel extends Component<Signature> {
{{/if}}
</div>
<div class='markdown-embed-chooser-tab-panel__right'>
{{#if this.hasPreview}}
{{#if this.showPane}}
<MarkdownEmbedPreviewPane
@target={{this.selectedTarget}}
@loading={{this.isLoading}}
@refType={{@refType}}
@selection={{@selection}}
@documentBaseUrl={{@documentBaseUrl}}
Expand Down
Loading
Loading