From e564d4300bbec055c2dd552c8dd4dbf171bc693f Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Sat, 5 Sep 2026 19:35:53 -0600 Subject: [PATCH] fix(artifacts): stop blaming a search the user never made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening "Shared with you" with nothing shared showed "No artifacts match your search" — with an empty search box. Found on dev the moment the inbox flag went live. `isFilteredEmpty` gated on the LIBRARY total, so any non-empty library made an empty tab look like a failed search. It needed the SELECTED TAB's count instead. The irony is that the comment above it already warned about exactly this conflation ("'Nothing matches' is a different message from 'you have nothing'") — tabs added a third state, "nothing *here*", and the old gate quietly folded it into the wrong one. So there are now three, in priority order: isEmpty nothing anywhere "No artifacts yet" + CTA isTabEmpty nothing in this tab names the tab isFilteredEmpty filtered to nothing "No artifacts match your search" `isEmpty` still wins when the library is empty outright: a per-tab message would bury the one statement that actually matters. Why the tests missed it: every empty-state spec asserted which ROWS rendered, never which SENTENCE appeared when none did. The three added here assert the sentence, including the case where blaming the search is correct. SPA suite: 2431 passed. Co-Authored-By: Claude Opus 5 --- .../app/artifacts/artifact-library.page.html | 15 +++++- .../artifacts/artifact-library.page.spec.ts | 51 +++++++++++++++++++ .../app/artifacts/artifact-library.page.ts | 45 ++++++++++++++-- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/frontend/ai.client/src/app/artifacts/artifact-library.page.html b/frontend/ai.client/src/app/artifacts/artifact-library.page.html index 1efc2bdf..4421dfff 100644 --- a/frontend/ai.client/src/app/artifacts/artifact-library.page.html +++ b/frontend/ai.client/src/app/artifacts/artifact-library.page.html @@ -229,7 +229,7 @@

No artifact [attr.role]="sharedAvailable() ? 'tabpanel' : null" [attr.aria-labelledby]="sharedAvailable() ? 'artifact-tab-' + tab() : null" > - + @if (isFilteredEmpty()) {
No artifact
} + + @if (isTabEmpty()) { +
+

+ {{ emptyTabMessage() }} +

+
+ } + @if (!loading() && filtered().length > 0 && viewMode() === 'list') {
    { typeOptions: () => Array<{ value: string; label: string }>; isEmpty: () => boolean; isFilteredEmpty: () => boolean; + isTabEmpty: () => boolean; + emptyTabMessage: () => string; error: () => string | null; loading: () => boolean; search: { set: (v: string) => void }; @@ -521,6 +523,55 @@ describe('ArtifactLibraryPage', () => { expect(thumbnail.closest('[aria-hidden="true"]')).not.toBeNull(); }); + it('does not blame a search the user never made', async () => { + // Regression: opening "Shared with you" with nothing shared used to + // show "No artifacts match your search" because the empty-state + // gate read the LIBRARY total rather than the tab's. Found on dev. + mockHttp.listLibrary.mockResolvedValue([stubArtifact()]); + mockShares.listSharedWithMe.mockResolvedValue({ + artifacts: [], + nextCursor: null, + }); + const c = api(await createComponent()); + c.setTab('shared'); + + expect(c.isFilteredEmpty()).toBe(false); + expect(c.isTabEmpty()).toBe(true); + expect(c.emptyTabMessage()).toContain('shared with you'); + }); + + it('still blames the search when there really was one', async () => { + mockHttp.listLibrary.mockResolvedValue([ + stubArtifact({ title: 'Budget model' }), + ]); + mockShares.listSharedWithMe.mockResolvedValue({ + artifacts: [], + nextCursor: null, + }); + const c = api(await createComponent()); + c.setTab('yours'); + c.search.set('nothing matches this'); + + expect(c.isTabEmpty()).toBe(false); + expect(c.isFilteredEmpty()).toBe(true); + }); + + it('reports an empty library ahead of an empty tab', async () => { + // With nothing anywhere, "No artifacts yet" is the true statement; + // a per-tab message would bury the one that matters. + mockHttp.listLibrary.mockResolvedValue([]); + mockShares.listSharedWithMe.mockResolvedValue({ + artifacts: [], + nextCursor: null, + }); + const c = api(await createComponent()); + c.setTab('shared'); + + expect(c.isEmpty()).toBe(true); + expect(c.isTabEmpty()).toBe(false); + expect(c.isFilteredEmpty()).toBe(false); + }); + it('falls back to a placeholder title and an undated label', async () => { mockHttp.listLibrary.mockResolvedValue([ stubArtifact({ title: '', updatedAt: '' }), diff --git a/frontend/ai.client/src/app/artifacts/artifact-library.page.ts b/frontend/ai.client/src/app/artifacts/artifact-library.page.ts index 19f103b5..b3ac21e7 100644 --- a/frontend/ai.client/src/app/artifacts/artifact-library.page.ts +++ b/frontend/ai.client/src/app/artifacts/artifact-library.page.ts @@ -383,16 +383,55 @@ export class ArtifactLibraryPage { ); /** - * "Nothing matches" is a different message from "you have nothing", and - * conflating them tells a user with a full library that it is empty. + * "Nothing matches" is a different message from "you have nothing" — + * and once tabs exist there is a third: "nothing *here*". + * + * This gates on the count of the SELECTED TAB, not the library. It + * used to gate on the library total, which meant opening "Shared with + * you" with nothing shared told the user "No artifacts match your + * search" when they had not searched for anything — a wrong answer, + * and the exact conflation the previous version of this comment was + * written to prevent, reintroduced one level up. Found on dev, not by + * a test: the specs asserted which rows rendered, never which sentence + * appeared when none did. */ protected readonly isFilteredEmpty = computed( () => !this.loading() && - this.totalCount() > 0 && + this.tabCount() > 0 && this.filtered().length === 0, ); + /** + * The selected tab holds nothing, but the library is not empty — so + * neither "No artifacts yet" nor a search failure is true. + */ + protected readonly isTabEmpty = computed( + () => + !this.loading() && + !this.error() && + this.totalCount() > 0 && + this.tabCount() === 0, + ); + + /** + * Copy for that state, which has to name the tab: "nothing here" is + * only useful if it says where "here" is. + */ + protected readonly emptyTabMessage = computed(() => { + switch (this.tab()) { + case 'shared': + return 'Nothing has been shared with you yet. Artifacts other ' + + 'people share with you directly will appear here.'; + case 'yours': + return "You haven't made any artifacts yet."; + default: + // Unreachable while "All" is the union of the other two, but a + // silent blank panel is the worst way to find out that changed. + return 'Nothing to show here.'; + } + }); + constructor() { void this.load(); }