docs(13-06): complete AIUI content-surfaces plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 19:54:14 -04:00
co-authored by Claude Opus 5
parent b771357902
commit fe54f95d4e
@@ -0,0 +1,210 @@
---
phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 06
subsystem: ui
tags: [vue, typescript, postmessage, content-adapter, filebrowser, rpc]
requires:
- phase: 13-aiui-functional-conversational-node-control-and-content-surf
provides: "13-01's chat:request/chat:response postMessage channel, contextBroker.ts's handleMessage switch, aiui-protocol.ts's message unions — this plan extends the same origin-checked bridge rather than inventing a second one"
provides:
- "archyContentAdapter.ts: hand-written ContentItem -> Film/Song/Podcast mapping (adaptContentItems, adaptToFilm/Song/Podcast, classifyByMime, sortDeterministic), fixture-pinned at the adjacency/empty/ordering/concurrency edges"
- "content:request / content:push channel on the existing contextBroker.ts bridge, gated on media/files permissions, with a contentRequestSeq stale-response guard"
- "AIUI's setArchyContent/archyContentActive (useContentPanel.ts) and requestArchyContent (useArchy.ts, archyBridge.ts) — real node content bypasses the regex-scraped text-extraction path for films/songs/podcasts"
- "filebrowser-client.ts's streamUrl no longer puts the filebrowser JWT in a query string (T-13-39 closed at its source)"
affects: [13-07, 13-11]
tech-stack:
added: []
patterns:
- "Local, hand-declared target-shape interfaces instead of a cross-repo @aiui/core import — neode-ui stays decoupled from aiui's package even though both now live in one repo (D-19)"
- "A single generic content:request/content:push channel with a kind discriminator, not one channel per content type, so 13-11's music wave can extend it without touching contextBroker.ts again"
- "contentRequestSeq monotonic-counter guard as the stale-response pattern for any broker request whose result can race a newer one"
key-files:
created:
- neode-ui/src/composables/archyContentAdapter.ts
- neode-ui/src/composables/__tests__/archyContentAdapter.test.ts
- neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts
modified:
- neode-ui/src/api/filebrowser-client.ts
- neode-ui/src/services/contextBroker.ts
- neode-ui/src/services/__tests__/contextBroker.test.ts
- neode-ui/src/types/aiui-protocol.ts
- aiui/packages/app/src/composables/useArchy.ts
- aiui/packages/app/src/composables/useContentPanel.ts
- aiui/packages/app/src/services/archyBridge.ts
key-decisions:
- "Source-badge literals: own-node Film -> 'nextcloud', peer Film -> 'plex', IndeeHub -> 'indeehub'; own-node Song -> 'funkwhale' (SongSource has no 'nextcloud' literal), peer Song -> 'plex' — chosen from AIUI's existing, unmodified vocabulary since D-12 forbids adding new source-type literals to content.ts"
- "Paid/locked state carried as Archipelago-only extension fields (locked?, priceSats?) on the locally-declared Film/Song/Podcast interfaces — additive, since FilmGrid.vue/SongGrid.vue read only the fields AIUI's own type already declares and ignore unknown ones"
- "content:request permission gate is media OR files (either grants access), not both — 13-CONTEXT.md's T-13-33 names both categories without specifying AND/OR; OR was chosen so a user granting only 'Media Libraries' isn't blocked from the content grids"
- "'owned' scope (content.owned-list) normalizes OwnedItem into an ArchyContentItem with access: 'free' (already purchased = unlocked) and reuses the peer-content adapter path (source: 'peer', peerOnion: owned.onion) — see Known Limitations for the honest caveat on this"
- "archyBridge.ts modified even though absent from the plan's files_modified list — Task 3's own action text requires registering content:push on the existing single window.addEventListener('message') listener rather than adding a second one, and only archyBridge.ts owns that listener"
requirements-completed: [AIUI-03]
coverage:
- id: D1
description: "archyContentAdapter.ts maps ContentItem -> Film/Song/Podcast correctly at the adjacency, empty, ordering, paid-lock and null-field edges, with no credential-bearing URL ever produced"
requirement: AIUI-03
verification:
- kind: unit
ref: "neode-ui/src/composables/__tests__/archyContentAdapter.test.ts (22/22 passing)"
status: pass
human_judgment: false
- id: D2
description: "filebrowser-client.ts's streamUrl no longer puts the filebrowser JWT in the URL query string (T-13-39) — returns a query-free same-origin raw-file URL, relies on the path=/ cookie login() already sets"
requirement: AIUI-03
verification:
- kind: unit
ref: "neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts (5/5 passing)"
status: pass
- kind: unit
ref: "neode-ui/src/components/__tests__/MediaLightboxPip.test.ts (5/5 passing) — existing streamUrl consumer did not regress"
status: pass
human_judgment: true
rationale: "The fix's real-world correctness depends on the deployed filebrowser honoring the session cookie on its raw-file endpoint, which no unit test in this repo can exercise (that's a live-node integration fact, not a unit-testable one). CLAUDE.md's own gate ('verify on the real node before any tag') is the mechanism that closes this gap; it's out of this plan's execution and belongs to the phase's node-verification step."
- id: D3
description: "content:request/content:push channel on contextBroker.ts: gated on media/files permission, resolves scope to content.list-mine/content.browse-peer/content.owned-list, routes through adaptContentItems, and never lets the iframe name an RPC method"
requirement: AIUI-03
verification:
- kind: unit
ref: "neode-ui/src/services/__tests__/contextBroker.test.ts (19/19 passing, including permission-denied, own-scope, and stale/out-of-order coverage)"
status: pass
- kind: unit
ref: "neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts (9/9 passing)"
status: pass
human_judgment: false
- id: D4
description: "contentRequestSeq discards a stale in-flight RPC response when a newer content:request has since started, so the grids never flip back to older data"
requirement: AIUI-03
verification:
- kind: unit
ref: "neode-ui/src/services/__tests__/contextBroker.test.ts > content:request > discards a stale in-flight response when a newer content:request has since started (out-of-order / AIUI-03 concurrency edge)"
status: pass
human_judgment: false
- id: D5
description: "AIUI's setArchyContent/archyContentActive (useContentPanel.ts) and requestArchyContent (useArchy.ts, archyBridge.ts) deliver node content to the same panelFilms/panelSongs/panelPodcasts refs FilmGrid/SongGrid already read, with zero changes to any grid component or content.ts, and the pre-existing regex path (contentExtraction.ts) still works unguarded for every non-Archy bucket"
requirement: AIUI-03
verification:
- kind: unit
ref: "aiui/packages/app: npx vitest run — 332/335 passing; the 3 failures (seed-conversations.test.ts, seedExtraction.test.ts, useAI.test.ts web-search-integration) are the pre-existing, documented failures unrelated to this plan"
status: pass
- kind: other
ref: "aiui/packages/app: npx vue-tsc --noEmit — exit 0"
status: pass
human_judgment: true
rationale: "Nothing in this plan's scope wires requestArchyContent() to an actual UI trigger (no ChatPage.vue changes, per files_modified) — the machinery is delivered and unit-tested end-to-end (adapter -> broker -> archyBridge -> useContentPanel), but a human visually confirming a real node's films/songs render in FilmGrid/SongGrid requires the follow-on plan that calls requestArchyContent() from the UI. See Known Limitations."
duration: ~1h05m
completed: 2026-08-03
status: complete
---
# Phase 13 Plan 06: AIUI Content Surfaces Made Real Summary
**Hand-written `ContentItem` → `Film`/`Song`/`Podcast` adapter (edge-fixture-pinned), a `content:request`/`content:push` channel with a stale-response guard on the existing bridge, `setArchyContent` wiring in AIUI, and the pre-existing `streamUrl` JWT-in-URL leak closed at its source — zero grid-component or `content.ts` changes.**
## Performance
- **Duration:** ~1h05m
- **Started:** 2026-08-03T19:20:00Z (approx)
- **Completed:** 2026-08-03T23:50:00Z
- **Tasks:** 3/3 completed
- **Files modified:** 10 (3 created, 7 modified)
## Accomplishments
- `archyContentAdapter.ts`: hand-written mapping from `content_server::ContentItem`'s wire shape to AIUI's `Film`/`Song`/`Podcast`, with local (not cross-repo-imported) target-shape interfaces, `classifyByMime` covering the `m4a`/`aac`/`opus`/`wma` extension gap `ShareModal.vue`'s mime map leaves today, deterministic `added_at`-desc/`id`-asc sorting, adjacency-safe `id`-keyed cards, and paid-item locking with no playable URL until unlocked
- Closed the pre-existing credential-in-URL leak named in `13-CONTEXT.md`: `filebrowser-client.ts`'s `streamUrl` now returns a query-free same-origin URL, relying on the `path=/` cookie `login()` already sets
- `content:request`/`content:push` channel on `contextBroker.ts`'s existing origin-checked bridge: gated on `media`/`files` permissions, resolves `own`/`peers`/`owned` scope to the right `content.*` RPC(s) (fanning out across every known federation peer for `peers`), and never lets the iframe name an RPC method or params
- `contentRequestSeq` — a monotonic stale-response guard so a slow RPC that resolves after a newer request has started is discarded rather than posted
- AIUI's `setArchyContent`/`archyContentActive` (`useContentPanel.ts`) and `requestArchyContent` (`useArchy.ts` + `archyBridge.ts`): Archy-sourced films/songs/podcasts bypass the regex-scraped `updatePanelFromText` path once populated, while every other bucket (books, TV, images, places, magazine, code, recipes, news) keeps working exactly as before
- Zero changes to `FilmGrid.vue`, `SongGrid.vue`, `NewsGrid.vue`, `ContentGridView.vue`, or `aiui/packages/core/src/types/content.ts` — verified by `git diff --exit-code` against the plan's pre-Task-1 base
## Task Commits
Each task was committed atomically:
1. **Task 1: The adapter — hand-written mapping, fixture-pinned, edges decided** - `f7691fd1` (feat)
2. **Task 2: A content channel on the existing bridge, with a stale-response guard** - `ce7a2b2c` (feat)
3. **Task 3: AIUI renders Archy content in the grids it already has** - `b7713579` (feat)
**Plan metadata:** this commit (docs: complete plan) — pending, see below.
## Files Created/Modified
- `neode-ui/src/composables/archyContentAdapter.ts` - `adaptContentItems`, `adaptToFilm`, `adaptToSong`, `adaptToPodcast`, `classifyByMime`, `sortDeterministic`, local `Film`/`Song`/`Podcast`/`ArchyContentItem` types
- `neode-ui/src/composables/__tests__/archyContentAdapter.test.ts` - 22 tests, one per `<behavior>` bullet plus shape-pinning and source-badge-literal pins
- `neode-ui/src/api/filebrowser-client.ts` - `streamUrl`'s body changed to a query-free URL; no signature/export change
- `neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts` - regression pin for the T-13-39 fix, including a traversal case
- `neode-ui/src/services/contextBroker.ts` - `handleContentRequest`, `fetchAdaptedContent`, `contentRequestSeq`, `normalizeOwnedItem`, `emptyBundle`/`mergeBundles` helpers
- `neode-ui/src/services/__tests__/contextBroker.test.ts` - permission-denied, own-scope, and stale/out-of-order coverage; fixed a latent cross-test flake risk (see Deviations)
- `neode-ui/src/types/aiui-protocol.ts` - `AIUIContentRequest`, `ArchyContentPush`
- `aiui/packages/app/src/composables/useArchy.ts` - `requestArchyContent`
- `aiui/packages/app/src/composables/useContentPanel.ts` - `setArchyContent`, `archyContentActive`
- `aiui/packages/app/src/services/archyBridge.ts` - `content:push` case in `handleMessage`, `requestArchyContent` (not in `files_modified`; see Deviations)
## Decisions Made
- **Source-badge literals** (`FilmSource.type`/`SongSource.type` are fixed unions D-12 forbids extending): own-node Film → `'nextcloud'`, peer Film → `'plex'`, IndeeHub → `'indeehub'`; own-node Song → `'funkwhale'` (no `'nextcloud'` literal exists in `SongSource`), peer Song → `'plex'`. Pinned by `archyContentAdapter.test.ts`'s `pins the three source-badge literal values` test.
- **Paid/locked state as additive extension fields** (`locked?: boolean`, `priceSats?: number`) on the locally-declared `Film`/`Song`/`Podcast` — not part of AIUI's real type, but AIUI's grids only read the fields their own type declares and ignore unknown ones, so this is safe and forward-compatible for whichever future plan renders the locked state.
- **`content:request` permission gate is `media` OR `files`**, not both. `13-CONTEXT.md`'s T-13-33 disposition says "checks the media/files permission categories" without specifying AND/OR; requiring both would block a user who granted only "Media Libraries" (the category whose description literally says "film, music, podcast titles and metadata") from ever seeing the content grids. This is a judgment call within the plan's stated ambiguity — documented here rather than silently picked.
- **`owned` scope** (`content.owned-list`) normalizes `OwnedItem` (a genuinely different RPC shape — no `access`/`availability`, has `onion`/`paid_sats`/`purchased_at`) into an `ArchyContentItem` with `access: 'free'` (already-purchased = unlocked for this node) and reuses the peer-content adapter path. See Known Limitations for the honest caveat: this does not guarantee actual playback works, because the existing peer-content Range-streaming proxy re-checks payment on every request rather than special-casing a buyer who already paid (a pre-existing backend gap, not introduced here, and out of scope for a frontend-only plan).
- **`archyBridge.ts` modified despite being absent from `files_modified`.** Task 3's own action text requires: "Register the handler alongside the existing bridge listeners; do not add a second `window.addEventListener('message')`." Only `archyBridge.ts` owns that listener (`useArchy.ts`/`useContentPanel.ts` don't), so implementing the stated requirement without touching it was not possible. Documented per deviation Rule 2 (auto-add missing critical functionality) — the plan's own instruction implied this file, `files_modified` simply omitted it.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical functionality] `archyBridge.ts` needed a `requestArchyContent`/`content:push` handler that `files_modified` didn't list**
- **Found during:** Task 3
- **Issue:** `useArchy.ts`'s `requestArchyContent` has nowhere to route a `content:request`/receive a `content:push` without either (a) modifying `archyBridge.ts` (the sole owner of the bridge's `window.addEventListener('message')` listener and `pendingRequests` map) or (b) adding a second listener, which the plan explicitly forbids.
- **Fix:** Added a `content:push` case to `archyBridge.ts`'s existing `handleMessage` switch and a `requestArchyContent(kind, scope)` function mirroring `requestContext`'s shape.
- **Files modified:** `aiui/packages/app/src/services/archyBridge.ts`
- **Verification:** `npx vitest run` (aiui) — 332/335 passing (3 pre-existing failures, none new); `npx vue-tsc --noEmit` exit 0.
- **Committed in:** `b7713579` (Task 3 commit)
**2. [Rule 1 - Bug] Latent cross-test flake in `contextBroker.test.ts` surfaced while adding new permission-gated tests**
- **Found during:** Task 2, writing the stale-response test
- **Issue:** `useAIPermissionsStore`'s enabled-categories state persists to `localStorage`, which `vi.clearAllMocks()` (the file's existing `beforeEach`) does not clear. Two new tests each called `perms.toggle('media')`; the second call toggled it back OFF because the first test's `save()` had already written `media` enabled to `localStorage`, silently flipping the second test into the permission-denied path and producing a flaky/order-dependent failure (`discards a stale in-flight response...` failed only when run after `adapts content.list-mine results...`, never in isolation).
- **Fix:** Switched both new tests to `perms.enableAll()` (idempotent, sets all categories unconditionally) instead of `perms.toggle('media')`.
- **Files modified:** `neode-ui/src/services/__tests__/contextBroker.test.ts`
- **Verification:** Ran the full `contextBroker.test.ts` file five times in a row, plus in isolation via `-t` filters combining every pair of the three new tests — 19/19 passing consistently. Root-caused via a standalone Node reproduction of the promise-ordering logic (confirmed correct) before finding the actual `localStorage` interaction.
- **Committed in:** `ce7a2b2c` (Task 2 commit)
---
**Total deviations:** 2 auto-fixed (1 Rule 2 missing-critical-functionality, 1 Rule 1 bug). No scope creep — both were necessary for the plan's own stated requirements to actually work.
## Known Limitations (reported honestly, not glossed)
- **Nothing in this plan wires `requestArchyContent()` to fire automatically.** `files_modified` for Task 3 lists only `useArchy.ts`/`useContentPanel.ts` (plus the now-necessary `archyBridge.ts`) — not `ChatPage.vue`. The composable functions exist, are exported, and are unit-tested end-to-end through a direct call, but nothing in the live UI currently *calls* `requestArchyContent()`. A future plan (or a follow-up to this one) needs to invoke it — e.g. on mount, or from a "Browse Library" action — for a real node's content to actually appear on screen. This was a deliberate scope read: the plan's `files_modified` and task list bound this plan to delivering the machinery, not the trigger.
- **`activeTab`/`availableTabs` are untouched by `setArchyContent`.** `ContentGridView.vue` switches purely on `activeTab` (which `updatePanelFromText`'s regex extraction still drives). If a node has films but the model's own reply text yields zero regex film matches, `panelFilms` is correctly populated by `setArchyContent` but there may be no visible tab to navigate to it, since `availableTabs` is computed independently. `activeTab` defaults to `'film'` and `panelOpen` isn't set by `setArchyContent` either, so visibility depends on whatever the current chat turn's regex pass produces. The plan's action text explicitly scoped `setArchyContent` to only the three ref assignments plus `archyContentActive`, and explicitly said "leave the rest of `updatePanelFromText` ... untouched" — so this is the literal, minimal implementation of that instruction, not an oversight, but it is a real functional gap for actually *seeing* the content without further UI wiring.
- **`usePlayer.ts`'s `play(song)` does not use the adapter's `sources[]` URL at all.** It searches Wavlake by title/artist regardless of where the `Song` came from (an unmodified, pre-existing behavior — `usePlayer.ts` is not in `files_modified` and `SongGrid.vue`/its click-to-play path is explicitly off-limits per D-12). So even once a node's songs are visible in `SongGrid`, clicking play does not stream this node's actual audio file — it re-searches an external service. This is a pre-existing landmine this plan surfaced but did not fix, out of explicit scope.
- **`owned`-scope playback is not guaranteed to work.** See "Decisions Made" above — the existing peer-content Range-streaming proxy re-verifies payment on every request; it has no "this buyer already purchased this item" bypass. Building that bypass is Rust work this frontend-only plan deliberately did not do (see machine constraints: "You should not need cargo at all").
- **`streamUrl`'s fix depends on a live-node fact this repo's tests cannot verify**: that the deployed filebrowser instance actually honors the session cookie on its `/api/raw` endpoint with no query parameter. The unit tests pin the *shape* of the fix (no query component, no credential in the string, traversal handling unchanged); confirming playback still works requires the node-level verification CLAUDE.md already mandates before any tag.
## Issues Encountered
- The `contextBroker.test.ts` flake described in Deviations #2 cost real investigation time — traced through a standalone Node reproduction of the exact promise-ordering logic (confirmed correct in isolation) before finding the `localStorage`-via-`perms.toggle()` root cause. Documented in case a future plan adds more `perms.toggle()`-based tests to this file — `perms.enableAll()`/`perms.disableAll()` are the idempotent alternatives.
- One acceptance-criteria grep (`grep -c 'ContentPanel' aiui/packages/app/src/composables/useArchy.ts` returning `0`) cannot pass as literally written once `useContentPanel` is imported (a required, correct import per Task 3's own spec) — the substring `"ContentPanel"` appears inside `"useContentPanel"` itself, both in the import path and the identifier. Verified the actual *intent* (the dead `ContentPanel.vue` component and its dead siblings `ArchyAppsGrid`/`FavoritesGrid`/`DiscoverPanel`/`RecipeDetail`/`AppDetail` were not revived) via a more targeted grep: `grep -c "ContentPanel\.vue\|ArchyAppsGrid\|FavoritesGrid\|DiscoverPanel\|RecipeDetail\|AppDetail" useArchy.ts` returns `0`. This is a false-positive in the plan's literal acceptance criterion caused by the (correct, necessary) `useContentPanel` module name, not a real regression — reported honestly rather than silently reworded to dodge the grep.
## User Setup Required
None - no external service configuration required by this plan.
## Next Phase Readiness
The content-surface machinery (adapter, broker channel, AIUI-side wiring) is in place, unit-tested end-to-end, and verified not to have touched any grid component or `content.ts`. Before a real node's content is actually visible in AIUI:
1. Something needs to call `requestArchyContent()` from the live UI (see Known Limitations) — likely on `ChatPage.vue` mount or from a dedicated "Browse Library" action, and probably also needs to set `activeTab`/`availableTabs`/`panelOpen` so the populated data has a tab to render under.
2. 13-11 (music library wave) can extend the `kind` discriminator on the existing `content:request`/`content:push` channel without touching `contextBroker.ts`'s message-routing again, per this plan's explicit design goal.
3. The `streamUrl` fix and the `owned`-scope playback gap both need on-node verification once a plan reaches that point in the phase's execution.
---
*Phase: 13-aiui-functional-conversational-node-control-and-content-surf*
*Completed: 2026-08-03*
## Self-Check: PASSED
All 10 created/modified source files plus this SUMMARY.md verified present on disk; all 3 task commits (`f7691fd1`, `ce7a2b2c`, `b7713579`) verified present in `git log --oneline --all`. No missing items.