Skip to content

Add Workspace base card (with test coverage) as the realm index card#5582

Open
lukemelia wants to merge 2 commits into
mainfrom
cs-12277-extract-workspace-carddef
Open

Add Workspace base card (with test coverage) as the realm index card#5582
lukemelia wants to merge 2 commits into
mainfrom
cs-12277-extract-workspace-carddef

Conversation

@lukemelia

@lukemelia lukemelia commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What

Adds Workspace (packages/base/workspace.gts), a base CardDef built to replace CardsGrid as a realm's index card, together with its tests.

This PR lands the card and its tests only — it does not change which index card any realm uses. No realm-creation code or index.json is touched here, so new realms still get a CardsGrid index and existing realms are unchanged. Making Workspace the default for newly-created realms (create-realm.ts) and migrating existing realms are separate changes (see Scope).

The card bundles the existing CardsGrid experience under a Library tab and adds:

  • Home — setup-progress bar, pinned entry-point "door" tiles, collapsible README hero, browse chips by card/file type, and a live space-details strip (visibility, counts, published sites, route count).
  • Activity — a reverse-chron event feed with day headers, reveal-on-scroll (20 at a time, capped at 100), loaded lazily when the tab opens.
  • Frame — a Cmd+K typeahead search, realm-scoped.
  • Edit — a settings format including realm publish/republish.

How it wires into existing infrastructure

  • Library delegates to the existing CardsGridLayout (reusing its VIEW_OPTIONS/SORT_OPTIONS/FilterOption), so the current grid behavior is reachable unchanged.
  • The readme field uses MarkdownDef; realm facts come from RealmInfo and the realm _types endpoint; realm change notifications come from subscribeToRealm.
  • Publish wraps the host publish-realm tool, invoked with a typed PublishRealmInput instance.
  • Setup-progress and remix jobs are discovered by querying the realm for ProcessCard / RemixCard via codeRef (not static imports), so this card compiles on its own and the Home job list populates once those base cards exist.

Port notes

Ported from the proof-of-concept: dropped the edit-tracking header and the superscript annotation markers, removed opaque private design-doc references, converted absolute base-realm import URLs to relative, removed the unused first VIEW_OPTIONS binding, and replaced the as any casts with real types. The current-module URL uses the same import.meta + @ts-ignore idiom as the rest of packages/base.

Tests

  • UnitetaMinutes (exported, with an injectable now) projection + every early-return + the >30-minute suppression; classifyActivityVerb across the created-window boundary and missing timestamps.
  • Integration — segment switching across the Home/Library/Activity tabs and presence of the Frame search input; plus a render smoke test for the isolated and edit formats.
  • Acceptance — a realm whose index.json adopts Workspace renders its shell in operator mode and switches segments, exercising the live realm-backed paths (subscribeToRealm, the _types fetch, and the Activity feed).

Scope — what makes Workspace the actual replacement (separate changes)

  • Default index for new realmscreate-realm.ts currently hardcodes the CardsGrid index card; that must switch to Workspace.
  • Migrate existing realms — flip committed realm index.json files off CardsGrid.
  • Realm-server recognition — teach the publish handler + prerender fast-path to recognize Workspace adoption.
  • Plus accessibility/i18n, magic-number configurability, missing-RealmConfig fallback, and publish/unpublish test coverage.

Test plan

glint (ember-tsc --noEmit) clean over the full port; ember-template-lint and prettier clean. Run locally against a full dev stack, per module: unit 9 passed, integration segments 2 passed, render smoke 2 passed, acceptance 1 passed — 0 failed.

🤖 Generated with Claude Code

Workspace supersedes CardsGrid as a realm's index card: it bundles the
CardsGrid experience under a Library tab and adds Home (setup progress,
pinned entry points, README hero, space details), Activity (reverse-chron
event feed), a Cmd+K Frame search, and an Edit settings format including
realm publish/republish.

Library delegates to the existing CardsGridLayout; the README field uses
MarkdownDef; realm facts come from RealmInfo and the realm _types endpoint;
publish wraps the host publish-realm tool with a typed PublishRealmInput.
Setup-progress and remix jobs are discovered by querying the realm for
ProcessCard/RemixCard via codeRef, so the card compiles without them and
lights up once they exist.

Accessibility, magic-number configurability, missing-RealmConfig fallback,
prerender fast-path recognition, realm migration, and the full behavior
test suite are handled separately; this change is the type-clean port plus
a render smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 57m 49s ⏱️
3 609 tests 3 594 ✅ 15 💤 0 ❌
3 628 runs  3 613 ✅ 15 💤 0 ❌

Results for commit fec6fa1.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   13m 40s ⏱️ +10s
1 924 tests ±0  1 924 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 003 runs  ±0  2 003 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit fec6fa1. ± Comparison against earlier commit 0619aa1.

Export etaMinutes (with an injectable `now` for deterministic tests) and
extract classifyActivityVerb from the Activity feed's inline verb logic, so
both pure functions can be unit-tested directly.

- unit: etaMinutes projection/edge cases and the >30-minute suppression;
  classifyActivityVerb created-vs-updated window.
- integration: segment switching across the Home/Library/Activity tabs and
  presence of the Frame search input, via a bare render.
- acceptance: a realm indexed by Workspace renders its shell in operator
  mode and switches segments, exercising the live realm-backed data paths.

Publish/unpublish coverage is deferred until the realm-server recognizes
Workspace adoption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia changed the title Add Workspace base card as the realm index card Add Workspace base card (with test coverage) as the realm index card Jul 22, 2026
if (!this.args.context?.store) {
return;
}
let processRef = codeRef(here, './process-card', 'ProcessCard');

@lukemelia lukemelia Jul 22, 2026

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 🤖] The setup-progress feature is dead — these type refs resolve to nonexistent base-realm modules.

codeRef(here, './process-card', 'ProcessCard') and codeRef(here, './remix-card', 'RemixCard') resolve here (the base-realm module URL) against ./process-card / ./remix-card, i.e. <baseRealm>/process-card and <baseRealm>/remix-card. Neither module exists in packages/baseProcessCard/RemixCard live only as host test fixtures / in other realms.

So loadJobs's query { any: [{ type: processRef }, { type: remixRef }] } never matches anything, and the whole setup-progress UI is inert: the "Setting up …" strip on Home, the collapsing progress dock on Activity, and the Activity-tab attention dot never render even while a real setup job runs in the realm.

Worse case: if the realm-server errors on an unresolvable type ref (rather than returning empty), loadFilterList/refreshOnIndex re-fire loadJobs on every incremental index and each throws. Please confirm the intended module location for these two card types.

if (kind === 'file') {
fileTotal += summary.attributes.total ?? 0;
} else {
cardTotal += summary.attributes.total ?? 0;

@lukemelia lukemelia Jul 22, 2026

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 🤖] Rail counts are inflated because totals accumulate before the exclusion guards.

cardTotal += summary.attributes.total (and the fileTotal sibling) runs before the excludedTypeIds.includes(summary.id) and summary.id.endsWith('workspace/Workspace') guards a few lines down. So the Workspace index card itself (always present — it's the very card being rendered) plus any Cards Grid / CardDef instances get added into cardTotal.

But countFor returns cardTotal for the "Cards" row and cardTotal + fileTotal for "Everything", while those rows' queries exclude _cardType: 'Workspace' and _cardType: 'Cards Grid'. Net result: the sidebar shows e.g. "Everything (6)" / "Cards (6)" but the grid those rows open lists only 5 — off by at least one (the Workspace card counting itself) on every workspace. Move the += accumulation after the exclusion guards.

type='button'
class='rail-row
{{if
(eq option.displayName this.activeFilter.displayName)

@lukemelia lukemelia Jul 22, 2026

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 🤖] Rail selection + countFor key on displayName, which collides with user type names.

Selected styling here uses (eq option.displayName this.activeFilter.displayName) (also at the card/file-type rows), and countFor switches on option.displayName — but selectFilter sets activeFilter by object identity. If a realm defines a user card/file type whose displayName happens to be Cards, Files, or Everything:

  • both the library-group row and the user-type row highlight together when either is clicked, and
  • countFor returns the aggregate cardTotal/fileTotal for the user type's row instead of that type's own count.

Key the selected check and countFor on the filter identity (or a stable id) rather than displayName.

}
});

private refreshOnIndex = (ev: RealmEventContent) => {

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Plausible) refreshOnIndex reacts only to incremental index events, so full/copy reindexes leave the UI stale.

This guard runs loadFilterList / loadJobs / loadLatest / loadFeed only when ev.indexType === 'incremental'. After a full reindex or a realm copy/remix — which emit index events with a non-incremental indexType — the rail type list, counts, running-jobs dock, Home "latest" preview, and Activity feed stay stale until the user navigates away and back.

Note the primary user-triggered full-reindex path may be shielded: realm.ts broadcasts full-reindex invalidations via broadcastIncrementalInvalidationEvent (indexType incremental), so this only bites the true full/copy event paths. Worth confirming which reindex flows actually emit non-incremental events here and widening the guard to cover them.

await loader.import<typeof CardsGridModule>('@cardstack/base/cards-grid')
).CardsGrid;

Workspace = (

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Plausible) This unconditional import pulls the 3948-line workspace.gts graph into every setupBaseRealm test's beforeEach.

setupBaseRealm registers initialize() as a beforeEach, so this await loader.import('@cardstack/base/workspace') now runs for every rendering/integration test that uses the helper — synchronously loading workspace.gts plus its transitive graph (CardsGridLayout, several boxel-icons, BoxelInput, and the PublishRealmCommand host tool with its HostBaseTool/config chain) into the loader on each test, even tests that never touch Workspace.

The host suite is already documented to OOM shards and is gated by a memory-baseline check, so this added per-test retained module graph is a plausible trigger for the baseline check tripping red or a shard OOMing. Consider importing Workspace lazily (only in the tests that need it) rather than in the shared initialize().

}
});

private refreshOnIndex = (ev: RealmEventContent) => {

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Efficiency) refreshOnIndex fans out to 3–4 full realm searches on every incremental index event.

Each incremental index event triggers loadFilterList (a _types fetch), loadJobs, loadLatest, and loadFeed (when Activity is open) — 3–4 independent realm round-trips per event. During active editing (which produces frequent incremental index events — e.g. an AI setup flow writing many cards) this multiplies index-event traffic 3–4×. The tasks are restartable, so this isn't a correctness bug, but consider debouncing/coalescing the refresh, or only refreshing the panels currently visible.

return this.realms[0];
}

private searchRealm = async (query: Query): Promise<CardDef[]> => {

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Efficiency) searchRealm hard-forces page: { size: 100 }, so loadLatest hydrates up to 100 cards to read one title.

searchRealm spreads ...query then overrides page: { size: 100 }, ignoring any smaller page a caller passes. loadLatest only needs the single newest card (it sorts by lastModified desc and .find()s the first, reading just .cardTitle), but store.search hydrates up to 100 instances every time — on subscription setup and on every incremental index event. On a large realm that's 100 cards fetched and instantiated per index tick to display one line. Let callers pass their own page (default to 100 only when unspecified), and have loadLatest request size: 1.

}
};

get runningJobs() {

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Efficiency) runningJobs is uncached and re-filters jobComponents on every template access.

get runningJobs() runs .filter over jobComponents on each read, and the isolated template reads this.runningJobs / this.runningJobs.length at ~6 sites plus dockSummary. Since jobComponents is a TrackedArray, every re-render re-runs the filter multiple times. The sibling chip getters (contentCardChips, fileChips, systemTypeCount) are all @cached — mark this one @cached too so it recomputes only when its tracked inputs change.

}
});

private loadFilterList = restartableTask(async () => {

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Duplication) loadFilterList / setupRealmSubscription / teardownRealmSubscription / createCard are near-verbatim copies from cards-grid.gts.

These methods are near-identical to the same methods in packages/base/cards-grid.gts (loadFilterList 331–438, setupRealmSubscription 245–265, teardownRealmSubscription 239–243 — byte-for-byte identical, createCard 279–329). The _types fetch, error-construction block, excludedTypeIds list, kind grouping, and realm-subscription lifecycle now live in two places. Any fix to _types parsing or subscription teardown (e.g. a rolling-deploy compatibility change) has to be made twice, or one card silently diverges. Consider extracting a shared mixin/util.

query: {
filter: {
every: [
{ not: { eq: { _cardType: 'Cards Grid' } } },

@lukemelia lukemelia Jul 22, 2026

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 🤖] (Duplication) The Cards Grid / Workspace exclusion clause pair is repeated across 7 query sites.

This two-line { not: { eq: { _cardType: 'Cards Grid' } } } / 'Workspace' exclusion is repeated at 7 places (≈ lines 2301, 2406, 2456, 2650, 2818, 2853, 3233). If the Workspace displayName changes, or a third self-referential type needs hiding, all seven sites must change — miss one and the workspace/grid card leaks into the Library, search dropdown, Activity feed, or pin chooser. Extract a single excludeSystemCardsFilter (or const clause array) and spread it into each query.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant