Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4091883
Add Cards/Files/Definitions metadata to workspace favorites, reorder …
cmgardella Aug 3, 2026
b05dcfc
Serve workspace tile metadata from the info endpoints, with tests
lukemelia Aug 3, 2026
afc70b0
Cache the info-endpoint extras
lukemelia Aug 4, 2026
1011201
Keep the detailed extras off the per-realm /_info route
lukemelia Aug 4, 2026
1c2bbc9
Serve tile counts from a dedicated route, loaded lazily for favorites
lukemelia Aug 4, 2026
6cc769c
Guard index-counts test teardown against a half-finished setup
lukemelia Aug 4, 2026
fcf1e08
Refresh tile counts when a realm re-indexes
lukemelia Aug 4, 2026
05e0056
Route the menu-footer divider through a token, refresh a stale test c…
lukemelia Aug 4, 2026
e80d84e
Harden workspace-favorites metadata: real updated_at, count retries, …
Aug 5, 2026
eaf8701
Keep a pre-client realm-server login attempt from poisoning later logins
lukemelia Aug 5, 2026
09754f9
Live-refresh workspace footer timestamps + cover updated_at on writes
Aug 5, 2026
de303de
Fix CI: guard matrix-authed info fetches, fix create-realm test payload
Aug 5, 2026
4540b0d
Refresh the footer's updated_at optimistically instead of via a fetch
Aug 5, 2026
7707f0d
Scope the invalidation-subscriber console.error assertions to their o…
Aug 6, 2026
37cca05
Drop the redundant hasClient guard at the prefetchRealmInfos call site
Aug 6, 2026
5d33604
Address chooser review: tokens, focus ring, selection ring, top-bar c…
Aug 6, 2026
e1fb8ad
Reveal tile star/menu on card keyboard focus, not just when each is t…
Aug 6, 2026
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
1 change: 1 addition & 0 deletions packages/boxel-ui/addon/src/components/menu/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default class Menu extends Component<Signature> {
type='button'
class='boxel-menu__item__content'
role='menuitem'
data-menu-item-id={{menuItem.id}}
data-test-boxel-menu-item-text={{menuItem.label}}
{{on 'click' (fn this.invokeMenuItemAction menuItem.action)}}
disabled={{menuItem.disabled}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,10 @@ export default class SubmodeLayout extends Component<Signature> {
}

.submode-layout-top-bar-center {
flex: 1;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
min-width: 0;
Expand Down Expand Up @@ -722,6 +725,10 @@ export default class SubmodeLayout extends Component<Signature> {
.profile-icon-button {
--boxel-icon-button-width: var(--container-button-size);
--boxel-icon-button-height: var(--container-button-size);
/* Match the outline treatment used by the search and AI-assistant
icon buttons (see .ai-assistant-button/search-sheet), instead of
the Avatar component's default 2px solid white border. */
--profile-avatar-icon-border: var(--boxel-border-flexible);

background: none;

Expand Down
195 changes: 176 additions & 19 deletions packages/host/app/components/operator-mode/workspace-chooser/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ArchiveIcon from '@cardstack/boxel-icons/archive';
import Home from '@cardstack/boxel-icons/home';
import Shapes from '@cardstack/boxel-icons/shapes';
import { dropTask } from 'ember-concurrency';
import { modifier } from 'ember-modifier';

import { BoxelSelect } from '@cardstack/boxel-ui/components';
import { add, eq } from '@cardstack/boxel-ui/helpers';
Expand Down Expand Up @@ -156,6 +157,25 @@ export default class WorkspaceChooser extends Component<Signature> {
return urls;
}

// Newest first. Realms without a createdAt (e.g. a broken/orphaned realm
// reference) sort to the end rather than clumping at the front.
private sortByCreatedAtDesc = <T extends string>(urls: T[]): T[] => {
return [...urls].sort((a, b) => {
let aCreatedAt = this.realm.info(a).createdAt;
let bCreatedAt = this.realm.info(b).createdAt;
if (!aCreatedAt && !bCreatedAt) {
return 0;
}
if (!aCreatedAt) {
return 1;
}
if (!bCreatedAt) {
return -1;
}
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
});
};

private get filteredUserRealmIdentifiers() {
// Render in list order: `_realm-auth` enumerates realms
// newest-created-first and realms created mid-session are prepended, so
Expand All @@ -165,7 +185,9 @@ export default class WorkspaceChooser extends Component<Signature> {
}

private get filteredCatalogRealmIdentifiers() {
return this.filterByHosted(this.communityRealmIdentifiers);
return this.filterByHosted(
this.sortByCreatedAtDesc(this.communityRealmIdentifiers),
);
}

private get favoriteRealmIdentifiers() {
Expand All @@ -178,6 +200,29 @@ export default class WorkspaceChooser extends Component<Signature> {
return this.filterByHosted(filtered);
}

// Only favorited tiles render a Cards / Files / Definitions row, and the
// counts behind it are an aggregate over each realm's whole index — so they
// are requested here, for that set alone, rather than arriving with the realm
// info the chooser loads for every workspace. Installed as a modifier on the
// Favorites list so it runs after render and re-runs when the set of
// favorites changes; `loadIndexCounts` is fire-and-forget and skips realms
// already loaded or in flight, so the dashboard never waits on it.
// `revision` is passed but unread: ember-modifier re-runs a modifier when any
// argument changes, so taking it makes a re-index that marked counts stale
// re-trigger the load. Without it this would only re-run when *which* realms
// are favorited changes — never when the answer for those realms does.
private trackFavoriteCounts = modifier(
(_el: HTMLElement, [urls, _revision]: [string, number]) => {
if (urls) {
this.realm.loadIndexCounts(urls.split(' '));
}
},
);

private get favoriteCountsKey() {
return this.favoriteRealmIdentifiers.join(' ');
}

private get userWorkspacesEmptyMessage(): string | null {
if (
this.sortOrder === 'hosted-only' &&
Expand Down Expand Up @@ -208,10 +253,87 @@ export default class WorkspaceChooser extends Component<Signature> {
return null;
}

// Measured live from the Your Workspaces tile grid so favorited tiles can
// mirror however many tiles actually fit per row at the current viewport
// width (see measureWorkspaceGrid below).
@tracked private workspaceGridWidth = 0;
@tracked private workspaceTileWidth = 0;
@tracked private workspaceTileGap = 0;

private get tilesPerRow(): number {
if (!this.workspaceTileWidth) {
return 3;
}
let perRow = Math.floor(
(this.workspaceGridWidth + this.workspaceTileGap) /
(this.workspaceTileWidth + this.workspaceTileGap),
);
return Math.max(1, perRow);
}

// Two tiles' worth of Favorites-section width for every 3-across, rounded
// up — a row of 3 or 4 gives favorited tiles pairs, a row of 5 gives
// groups of 3, etc.
private get favoritesSlotCount(): number {
return Math.ceil(this.tilesPerRow / 2);
}

// The width a single favorited tile should be so that `favoritesSlotCount`
// of them, plus the gaps between them, exactly span the measured Your
// Workspaces row width. Null before the grid has been measured.
private get favoritesSlotWidth(): number | null {
if (!this.workspaceTileWidth) {
return null;
}
let n = this.favoritesSlotCount;
let rowWidth =
this.tilesPerRow * this.workspaceTileWidth +
(this.tilesPerRow - 1) * this.workspaceTileGap;
return (rowWidth - (n - 1) * this.workspaceTileGap) / n;
}

// Applied to real favorited tiles (via cssVar, see workspace.gts) so they
// take up this responsive width. Undefined before the grid has been
// measured, so the tile falls back to its static CSS width.
private get favoritesTileWidthPx(): string | undefined {
let width = this.favoritesSlotWidth;
return width === null ? undefined : `${width}px`;
}

// Watches the Your Workspaces tile grid so we always know how many tiles
// currently fit per row (and their exact width/gap), regardless of how
// many workspaces the user actually has.
private measureWorkspaceGrid = modifier((el: HTMLElement) => {
let measure = () => {
this.workspaceGridWidth = el.clientWidth;
let tile = el.querySelector('.workspace-card button.workspace');
if (tile) {
this.workspaceTileWidth = tile.getBoundingClientRect().width;
}
let gap = parseFloat(getComputedStyle(el).columnGap);
if (!Number.isNaN(gap)) {
this.workspaceTileGap = gap;
}
};
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(measure);
ro.observe(el);
}
window.addEventListener('resize', measure);
measure();
return () => {
ro?.disconnect();
window.removeEventListener('resize', measure);
};
});

// The keyboard-selected tile, identified by its position in the flat,
// DOM-ordered sequence of selectable tiles. The sequence spans, in render
// order: Favorites, Your Workspaces, the "New Workspace" tile, then Catalogs.
@tracked private selectedIndex = 0;
// order: Favorites, the "New Workspace" tile, Your Workspaces, then Catalogs.
// `null` until the user moves the selection, at which point
// `defaultSelectedIndex` no longer applies.
@tracked private selectedIndex: number | null = null;

private get favoritesCount() {
return this.favoriteRealmIdentifiers.length;
Expand All @@ -235,23 +357,38 @@ export default class WorkspaceChooser extends Component<Signature> {
}

// navIndex of the first tile in each section. The "New Workspace" tile sits
// between Your Workspaces and Catalogs.
private get userWorkspacesNavBase() {
// first in Your Workspaces, ahead of the actual workspace tiles.
private get addWorkspaceNavIndex() {
return this.favoritesCount;
}

private get addWorkspaceNavIndex() {
return this.favoritesCount + this.userWorkspacesCount;
private get userWorkspacesNavBase() {
return this.favoritesCount + (this.isAddWorkspaceShown ? 1 : 0);
}

private get catalogNavBase() {
return this.addWorkspaceNavIndex + (this.isAddWorkspaceShown ? 1 : 0);
return this.userWorkspacesNavBase + this.userWorkspacesCount;
}

private get selectableCount() {
return this.catalogNavBase + this.renderedCatalogCount;
}

// Where the selection sits before the user has moved it. The "New Workspace"
// tile renders first within Your Workspaces, but it must not be what opening
// the chooser lands on: the selected tile takes focus, so starting there
// would make the first Enter create a workspace instead of opening one. Skip
// past it to the first real workspace whenever there is one.
private get defaultSelectedIndex() {
if (this.favoritesCount > 0) {
return 0;
}
if (this.userWorkspacesCount > 0) {
return this.userWorkspacesNavBase;
}
return 0;
}

// `selectedIndex` can fall out of range when the selectable set shrinks
// without a keypress (e.g. switching to the Hosted Only filter hides the
// user section and "New Workspace" tile). Clamping on read keeps a tile
Expand All @@ -262,7 +399,8 @@ export default class WorkspaceChooser extends Component<Signature> {
if (count === 0) {
return 0;
}
return Math.min(Math.max(this.selectedIndex, 0), count - 1);
let index = this.selectedIndex ?? this.defaultSelectedIndex;
return Math.min(Math.max(index, 0), count - 1);
}

// Keep the selection in sync with focus, so tabbing onto a tile selects it.
Expand All @@ -272,7 +410,13 @@ export default class WorkspaceChooser extends Component<Signature> {
return;
}
let index = Number((tile as HTMLElement).dataset.navIndex);
if (!Number.isNaN(index) && index !== this.selectedIndex) {
// Compare against the *effective* selection, not the raw backing field.
// The selected tile is focused by a modifier (`focusWhenSelected`), whose
// synchronous `focus()` re-enters here during the same render pass that
// just read `currentIndex`. Writing `selectedIndex` there trips Ember's
// backtracking-rerender assertion, so the already-selected case has to be
// a genuine no-op — which it isn't if we compare to a still-unset field.
if (!Number.isNaN(index) && index !== this.currentIndex) {
this.selectedIndex = index;
}
}
Expand Down Expand Up @@ -428,12 +572,21 @@ export default class WorkspaceChooser extends Component<Signature> {
data-test-favorites-empty
>{{this.favoritesEmptyMessage}}</span>
{{else}}
<div class='workspace-list' data-test-favorites-list>
<div
class='workspace-list'
data-test-favorites-list
{{this.trackFavoriteCounts
this.favoriteCountsKey
this.realm.indexCountsRevision
}}
>
{{#each this.favoriteRealmIdentifiers as |realmIdentifier i|}}
<Workspace
@realmIdentifier={{realmIdentifier}}
@navIndex={{i}}
@isSelected={{eq this.currentIndex i}}
@isFavoritesSection={{true}}
@enlargedWidth={{this.favoritesTileWidthPx}}
/>
{{/each}}
</div>
Expand All @@ -450,7 +603,18 @@ export default class WorkspaceChooser extends Component<Signature> {
data-test-workspaces-empty
>{{this.userWorkspacesEmptyMessage}}</span>
{{else}}
<div class='workspace-list' data-test-workspace-list>
<div
class='workspace-list'
data-test-workspace-list
{{this.measureWorkspaceGrid}}
>
<AddWorkspace
@navIndex={{this.addWorkspaceNavIndex}}
@isSelected={{eq this.currentIndex this.addWorkspaceNavIndex}}
/>
{{#if this.matrixService.isInitializingNewUser}}
<WorkspaceLoadingIndicator />
{{/if}}
{{#each
this.filteredUserRealmIdentifiers
as |realmIdentifier i|
Expand All @@ -464,13 +628,6 @@ export default class WorkspaceChooser extends Component<Signature> {
/>
{{/let}}
{{/each}}
{{#if this.matrixService.isInitializingNewUser}}
<WorkspaceLoadingIndicator />
{{/if}}
<AddWorkspace
@navIndex={{this.addWorkspaceNavIndex}}
@isSelected={{eq this.currentIndex this.addWorkspaceNavIndex}}
/>
</div>
{{/if}}
</div>
Expand Down
Loading
Loading