feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND)
AIUI asks for the library the same way it asks for content, and the
fetch now actually fires without anyone typing a magic phrase:
- useArchy.ts: requestArchyLibrary(scope) sibling of requestArchyContent
(13-06), same bridge call with kind: 'library', routed through the
existing setArchyContent so the songs bucket fills exactly the way
films already does.
- init() now calls both requestArchyContent('all','own') and
requestArchyLibrary('own') once, fire-and-forget, immediately after
archyBridge.init() — the GAP-FOUND fix. 13-06 built the whole
content:request/content:push machinery and unit-tested it end to end,
but nothing in the live UI ever called it (13-06-SUMMARY.md's Known
Limitations); the fetch is now triggered by a real init-time UI event,
not merely callable.
- useContentPanel.ts's setArchyContent now also opens the panel and
populates availableTabs/activeTab/panelTitle when Archy supplied
non-empty content — previously only the data refs were set while the
tab bar and panelOpen stayed whatever the last regex-driven chat turn
left them, so real content could sit fully populated and still never
render. An empty bundle never force-opens the panel.
Deviation (Rule 2, mirrors 13-06's own archyBridge.ts precedent): kind:
'library' genuinely needs a different node-side RPC (music.list-tracks,
real tag-extracted metadata) than content.* (ContentItem has no artist/
album/duration field at all) — contextBroker.ts's handleContentRequest
gained one branch (fetchLibraryContent) to route it, and
aiui-protocol.ts's AIUIContentRequest.kind union gained the 'library'
literal, and archyBridge.ts's requestArchyContent kind param widened to
match. No second channel, no new message type, no new listener — the
existing content:request/content:push channel and its kind discriminator
carry this exactly as 13-06 designed it to. Full detail in the SUMMARY.
neode-ui: 926/926 tests green, vue-tsc -b clean. aiui: 341/344 (3
pre-existing, documented failures unrelated to this plan — 13-06/13-10
already recorded them), vue-tsc --noEmit clean.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 13-11: requestArchyLibrary (sibling of 13-06's requestArchyContent) and
|
||||
* the init-time auto-trigger that fires both over the real bridge — the
|
||||
* GAP-FOUND fix. 13-06 built requestArchyContent/content:request/
|
||||
* content:push and unit-tested all of it, but nothing in the live UI ever
|
||||
* called it (see 13-06-SUMMARY.md's Known Limitations); this pins that the
|
||||
* fetch now fires from a real init-time event, not merely from a direct
|
||||
* unit-test call.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
type PostedMessage = { type: string; kind?: string; scope?: string; [key: string]: unknown }
|
||||
|
||||
describe('useArchy: requestArchyLibrary + init-time auto-trigger (13-11)', () => {
|
||||
let originalParent: Window
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
originalParent = window.parent
|
||||
Object.defineProperty(window, 'parent', {
|
||||
value: { postMessage: vi.fn() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'parent', {
|
||||
value: originalParent,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
delete (window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('requestArchyLibrary sends a content:request with kind "library"', async () => {
|
||||
const { useArchy } = await import('@/composables/useArchy')
|
||||
const archy = useArchy()
|
||||
archy.init()
|
||||
|
||||
archy.requestArchyLibrary('own').catch(() => {})
|
||||
|
||||
expect(window.parent.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'content:request', kind: 'library', scope: 'own' }),
|
||||
window.location.origin,
|
||||
)
|
||||
archy.destroy()
|
||||
})
|
||||
|
||||
it('init() fires both requestArchyContent and requestArchyLibrary as a live init-time event — not merely callable (GAP-FOUND)', async () => {
|
||||
const { useArchy } = await import('@/composables/useArchy')
|
||||
const archy = useArchy()
|
||||
|
||||
archy.init()
|
||||
|
||||
const postMessage = window.parent.postMessage as unknown as ReturnType<typeof vi.fn>
|
||||
const contentRequests = postMessage.mock.calls
|
||||
.map(([msg]) => msg as PostedMessage)
|
||||
.filter((msg) => msg.type === 'content:request')
|
||||
|
||||
expect(contentRequests.some((msg) => msg.kind === 'all')).toBe(true)
|
||||
expect(contentRequests.some((msg) => msg.kind === 'library')).toBe(true)
|
||||
archy.destroy()
|
||||
})
|
||||
|
||||
it('exposes requestArchyLibrary on the composable\'s returned API', async () => {
|
||||
const { useArchy } = await import('@/composables/useArchy')
|
||||
const archy = useArchy()
|
||||
expect(typeof archy.requestArchyLibrary).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,40 @@ describe('useContentPanel', () => {
|
||||
expect(panel.selectedImage.value).toBeNull()
|
||||
})
|
||||
|
||||
// 13-11 (GAP-FOUND 2026-08-03): setArchyContent must make real,
|
||||
// non-empty node content actually visible — populating panelFilms/
|
||||
// panelSongs/panelPodcasts alone left the tab bar and panelOpen
|
||||
// untouched, so real data sat in memory and never rendered.
|
||||
describe('setArchyContent makes non-empty content actually appear', () => {
|
||||
it('opens the panel and adds a song tab when songs are non-empty', () => {
|
||||
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
|
||||
panel.setArchyContent({ songs: [song] })
|
||||
expect(panel.panelOpen.value).toBe(true)
|
||||
expect(panel.availableTabs.value).toContain('song')
|
||||
expect(panel.availableTabs.value).toContain('prompt')
|
||||
expect(panel.activeTab.value).toBe('song')
|
||||
expect(panel.panelTitle.value).toBe('Song')
|
||||
})
|
||||
|
||||
it('adds a film tab and a song tab together when both are non-empty', () => {
|
||||
const film = { id: 'f1', title: 'Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
|
||||
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
|
||||
panel.setArchyContent({ films: [film], songs: [song] })
|
||||
expect(panel.availableTabs.value).toEqual(['film', 'song', 'prompt'])
|
||||
expect(panel.activeTab.value).toBe('film')
|
||||
})
|
||||
|
||||
it('does not force the panel open when the bundle is entirely empty', () => {
|
||||
panel.closePanel()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it('openPlaceDetail and closePlaceDetail work', () => {
|
||||
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
|
||||
panel.openPlaceDetail(place)
|
||||
|
||||
@@ -126,6 +126,22 @@ export function useArchy() {
|
||||
|
||||
// Request theme on init
|
||||
archyBridge.requestTheme()
|
||||
|
||||
// 13-11 (GAP-FOUND 2026-08-03): fire the content/library fetch from a
|
||||
// real init-time event instead of leaving requestArchyContent /
|
||||
// requestArchyLibrary merely callable with nothing in the live UI ever
|
||||
// calling them — 13-06 built the whole content:request/content:push
|
||||
// machinery and unit-tested it end to end, but nothing in ChatPage.vue's
|
||||
// render tree (or anywhere else) ever invoked it, so real node content
|
||||
// never appeared no matter how green the tests were (13-06's own Known
|
||||
// Limitations). `init()` runs once per embedded session (guarded by
|
||||
// `isInitialized` above) and is itself triggered by App.vue mounting —
|
||||
// a real UI event, not a user-typed phrase. Fire-and-forget: both
|
||||
// functions already resolve to a silent, logged no-op when the user
|
||||
// hasn't granted Media/File access (`permitted: false`), so this never
|
||||
// throws into `init()`.
|
||||
void requestArchyContent('all', 'own')
|
||||
void requestArchyLibrary('own')
|
||||
}
|
||||
|
||||
/** Fetch context for all permitted categories */
|
||||
@@ -233,6 +249,38 @@ export function useArchy() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the node's real music library (13-11 — the D-13 wave) and hand
|
||||
* it to the same `setArchyContent` sink `requestArchyContent` uses, so
|
||||
* `SongGrid`'s `songs` bucket fills exactly the way the films bucket
|
||||
* already does. Sibling of `requestArchyContent`, same bridge call, same
|
||||
* permitted/not-permitted shape — the `'library'` kind is what routes
|
||||
* this node-side to `music.list-tracks` (real tag-extracted metadata)
|
||||
* instead of `content.*` (see `archyBridge.ts`'s `requestArchyContent`
|
||||
* doc comment). `films`/`podcasts` are never touched by a library
|
||||
* request — only `songs` is meaningful for `kind: 'library'`, so this
|
||||
* merges into whatever films/podcasts `setArchyContent` last held rather
|
||||
* than clobbering them with empty arrays.
|
||||
*/
|
||||
async function requestArchyLibrary(scope: 'own' | 'peers' | 'owned' = 'own') {
|
||||
if (!isInitialized.value) return
|
||||
try {
|
||||
const res = await archyBridge.requestArchyContent('library', scope)
|
||||
if (!res.permitted) {
|
||||
console.warn('[AIUI Archy] library: not permitted — user should enable Media/File access in Archy Settings')
|
||||
return
|
||||
}
|
||||
const panel = useContentPanel()
|
||||
panel.setArchyContent({
|
||||
films: panel.panelFilms.value,
|
||||
songs: res.songs as Song[],
|
||||
podcasts: panel.panelPodcasts.value,
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[AIUI Archy] library fetch failed:', (err as Error)?.message ?? err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply accent color as CSS custom property */
|
||||
function applyAccentColor(color: string) {
|
||||
document.documentElement.style.setProperty('--color-accent', color)
|
||||
@@ -333,6 +381,7 @@ export function useArchy() {
|
||||
readFile,
|
||||
tailLogs,
|
||||
requestArchyContent,
|
||||
requestArchyLibrary,
|
||||
buildArchyContext,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,31 @@ export function useContentPanel() {
|
||||
panelSongs.value = bundle.songs ?? []
|
||||
panelPodcasts.value = bundle.podcasts ?? []
|
||||
archyContentActive.value = true
|
||||
|
||||
// 13-11 (GAP-FOUND 2026-08-03): `availableTabs`/`activeTab`/`panelOpen`
|
||||
// were previously untouched here — only `updatePanelFromText`'s regex
|
||||
// path ever set them, so real Archy-sourced content could sit fully
|
||||
// populated in these three refs while the tab bar and grid stayed
|
||||
// whatever the last (or no) chat turn left them: closed, or showing
|
||||
// only 'prompt' (13-06's own Known Limitations, this plan's must_haves
|
||||
// GAP-FOUND). Only touches these three refs when Archy actually
|
||||
// supplied non-empty content — an empty/never-granted library must not
|
||||
// force the panel open on every mount.
|
||||
const archyTabs: ContentTab[] = []
|
||||
if (panelFilms.value.length > 0) archyTabs.push('film')
|
||||
if (panelSongs.value.length > 0) archyTabs.push('song')
|
||||
if (panelPodcasts.value.length > 0) archyTabs.push('podcast')
|
||||
if (archyTabs.length > 0) {
|
||||
availableTabs.value = [...archyTabs, 'prompt']
|
||||
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
|
||||
if (panelFilms.value.length === 1) panelTitle.value = panelFilms.value[0]!.title
|
||||
else if (panelFilms.value.length > 1) panelTitle.value = `${panelFilms.value.length} Films`
|
||||
else if (panelSongs.value.length === 1) panelTitle.value = panelSongs.value[0]!.title
|
||||
else if (panelSongs.value.length > 1) panelTitle.value = `${panelSongs.value.length} Songs`
|
||||
else if (panelPodcasts.value.length === 1) panelTitle.value = panelPodcasts.value[0]!.title
|
||||
else if (panelPodcasts.value.length > 1) panelTitle.value = `${panelPodcasts.value.length} Podcasts`
|
||||
panelOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveTab(tab: ContentTab) {
|
||||
|
||||
@@ -299,9 +299,15 @@ export const archyBridge = {
|
||||
* false` and empty arrays if the media/files permission categories
|
||||
* aren't granted, rather than rejecting (mirrors `requestContext`'s
|
||||
* shape so `useArchy.ts` can check `.permitted` the same way).
|
||||
*
|
||||
* `'library'` (13-11) is `useArchy.ts`'s `requestArchyLibrary`'s kind —
|
||||
* resolves node-side to `music.list-tracks` instead of `content.*`
|
||||
* (`neode-ui`'s `aiui-protocol.ts`/`contextBroker.ts`), so the returned
|
||||
* `songs` carry real tag-extracted metadata rather than filename-derived
|
||||
* guesses.
|
||||
*/
|
||||
requestArchyContent(
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all',
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library',
|
||||
scope?: 'own' | 'peers' | 'owned',
|
||||
): Promise<ContentResponse> {
|
||||
const id = generateId()
|
||||
|
||||
Reference in New Issue
Block a user