fix(aiui): recommendation previews render in buckets the node left empty

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 09:16:52 -04:00
co-authored by Claude
parent a91bc55df8
commit 7d57e2c39d
2 changed files with 81 additions and 11 deletions
@@ -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', () => {
@@ -73,6 +73,14 @@ const mapPlaces = ref<Place[]>([])
* 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