From 7d57e2c39d78a505607edaca859ced3fad6506c2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 7 Aug 2026 09:16:52 -0400 Subject: [PATCH] fix(aiui): recommendation previews render in buckets the node left empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archy content latch was global and permanent: once mount-time content latched archyContentActive, no extracted [[film_ext:…]] recommendation card could ever render again, and an empty tool result kept the latch — so a 'recommend me films' turn beside an empty catalogue showed prose only, with 'Nothing found' overwriting nothing. The chat had lost its rich previews. - setArchyContent records which buckets the node actually supplied (archySupplied) and latches active only on a non-empty delivery - updatePanelFromText's no-overwrite guard is now per-bucket: node truth wins buckets it filled; empty buckets stay writable for extracted previews - the extraction fallback title no longer clobbers 'Nothing found'/'Loading…' Regression tests: previews render in an empty bucket, node truth survives tags in a filled bucket, 'Nothing found' survives a both-empty turn. Co-Authored-By: Claude --- .../__tests__/useContentPanel.test.ts | 55 ++++++++++++++++++- .../app/src/composables/useContentPanel.ts | 37 ++++++++++--- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts b/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts index 81acf12f..c21ae460 100644 --- a/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts +++ b/aiui/packages/app/src/composables/__tests__/useContentPanel.test.ts @@ -105,9 +105,11 @@ describe('useContentPanel', () => { panel.setArchyContent({ films: [], songs: [], podcasts: [] }) expect(panel.panelOpen.value).toBe(false) expect(panel.availableTabs.value).toHaveLength(0) - // archyContentActive still flips true — an explicit empty library - // is still a real answer from the node, distinct from "never asked". - expect(panel.archyContentActive.value).toBe(true) + // An empty delivery does NOT latch: nothing was supplied, so there is + // no node truth to protect and extraction must stay writable. The + // honest empty state is carried by the 'Nothing found' heading, not + // by the latch. + expect(panel.archyContentActive.value).toBe(false) }) // Regression (live on archi-dev-box 2026-08-07): "show me paid for peer @@ -133,6 +135,53 @@ describe('useContentPanel', () => { expect(panel.availableTabs.value[0]).toBe('image') expect(panel.panelImages.value).toHaveLength(1) }) + + // Regression (operator report 2026-08-07): "recommend me 10 scifi films" + // — the node has no films, the model's reply carried [[film_ext:…]] tags, + // and the global archy latch (set at mount by the node's own images) + // meant extraction could never write panelFilms: ten recommendations + // rendered as bare prose beside an empty panel. + it('renders extracted recommendation previews in a bucket the node left empty', () => { + const img = { id: 'i1', title: 'photo.jpg', url: '/content/x', mimeType: 'image/jpeg' } as unknown as ImageItem + panel.setArchyContent({ images: [img], films: [], songs: [], podcasts: [] }) + + // The turn: the films scope came back empty, the reply carries tags. + panel.beginArchyContentLoad() + panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] }) + panel.updatePanelFromText( + 'Here are two sci-fi films:\n\n[[film_ext:Blade Runner 2049|2017|Denis Villeneuve]] — A stunning neo-noir sequel.\n\n[[film_ext:Arrival|2016|Denis Villeneuve]] — Thoughtful first contact.', + 'recommend me sci-fi films', + ) + + expect(panel.panelFilms.value.map((f) => f.title)).toEqual(['Blade Runner 2049', 'Arrival']) + expect(panel.panelOpen.value).toBe(true) + expect(panel.availableTabs.value[0]).toBe('film') + }) + + it('never lets extracted tags overwrite a bucket the node actually filled', () => { + const song = { id: 's1', title: 'Real Node Song', artist: 'A', album: 'B', genres: [] } as Song + panel.setArchyContent({ songs: [song] }) + + panel.updatePanelFromText( + 'You might also like [[song_ext:Fake Song|Nobody|2020]]', + 'what music do I have', + ) + + expect(panel.panelSongs.value).toHaveLength(1) + expect(panel.panelSongs.value[0].title).toBe('Real Node Song') + }) + + it("keeps 'Nothing found' when the node and the reply both yield nothing", () => { + panel.closePanel() + panel.beginArchyContentLoad() + panel.setArchyContent({ films: [], songs: [], podcasts: [], images: [] }) + expect(panel.panelTitle.value).toBe('Nothing found') + + panel.updatePanelFromText('I could not find anything matching that on this node.', 'show me films') + + expect(panel.panelTitle.value).toBe('Nothing found') + expect(panel.panelOpen.value).toBe(false) + }) }) it('openPlaceDetail and closePlaceDetail work', () => { diff --git a/aiui/packages/app/src/composables/useContentPanel.ts b/aiui/packages/app/src/composables/useContentPanel.ts index 5b0ce213..24715b31 100644 --- a/aiui/packages/app/src/composables/useContentPanel.ts +++ b/aiui/packages/app/src/composables/useContentPanel.ts @@ -73,6 +73,14 @@ const mapPlaces = ref([]) * Archy source in this plan's scope and keeps using the regex path * unconditionally (13-PATTERNS.md: partial deprecation, not a removal). */ const archyContentActive = ref(false) +/** Which buckets the node's LATEST archy delivery actually filled. The + * regex-extraction path must never overwrite a bucket the node supplied + * (D-12 node truth), but a bucket the node left EMPTY stays writable so a + * recommendation reply's [[film_ext:…]] previews still render as cards — + * the global latch used to suppress exactly those (the "chat lost its rich + * previews" regression: once mount-time content latched the flag, no + * extracted card could ever render again). */ +const archySupplied = ref({ film: false, song: false, podcast: false, image: false }) /** A chat turn that may produce content is in flight. Until it resolves, * the panel heading must NOT keep advertising the previous query's * results — the operator reads that as the answer to what they just @@ -233,12 +241,12 @@ export function useContentPanel() { // joined this set when the adapter started carrying shared photos; // leaving them out here would have let the regex path immediately // wipe the grid the node had just filled. - if (!archyContentActive.value) { - panelFilms.value = visibleFilms - panelSongs.value = visibleSongs - panelPodcasts.value = visiblePodcasts - panelImages.value = visibleImages - } + // Per-bucket, not global: the node's truth wins a bucket it filled; + // an empty bucket stays open to this turn's extracted previews. + if (!archySupplied.value.film) panelFilms.value = visibleFilms + if (!archySupplied.value.song) panelSongs.value = visibleSongs + if (!archySupplied.value.podcast) panelPodcasts.value = visiblePodcasts + if (!archySupplied.value.image) panelImages.value = visibleImages panelBooks.value = visibleBooks panelTVSeries.value = visibleTVSeries panelPlaces.value = visiblePlaces @@ -325,7 +333,10 @@ export function useContentPanel() { else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps` else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites` else if (hasNostr) panelTitle.value = 'Nostr' - else panelTitle.value = 'Content' + // Zero extraction tabs: leave the title alone when an archy delivery + // just said 'Nothing found' (or is still 'Loading…') — the closed + // panel's heading must not silently become 'Content'. + else if (panelTitle.value !== 'Nothing found' && panelTitle.value !== 'Loading…') panelTitle.value = 'Content' // Open from the ORDERED tab list (Archy tabs + inferred), not the // regex-only `tabs`: a node-supplied grid whose reply text happens to @@ -366,7 +377,17 @@ export function useContentPanel() { panelSongs.value = bundle.songs ?? [] panelPodcasts.value = bundle.podcasts ?? [] panelImages.value = bundle.images ?? [] - archyContentActive.value = true + archySupplied.value = { + film: panelFilms.value.length > 0, + song: panelSongs.value.length > 0, + podcast: panelPodcasts.value.length > 0, + image: panelImages.value.length > 0, + } + // Latch only on a non-empty delivery: an empty tool result must not + // suppress the recommendation previews extraction can still produce. + archyContentActive.value = + archySupplied.value.film || archySupplied.value.song || + archySupplied.value.podcast || archySupplied.value.image // 13-11 (GAP-FOUND 2026-08-03): `availableTabs`/`activeTab`/`panelOpen` // were previously untouched here — only `updatePanelFromText`'s regex