--- phase: 13-aiui-functional-conversational-node-control-and-content-surf plan: 06 type: execute wave: 2 depends_on: ["13-01"] files_modified: - neode-ui/src/composables/archyContentAdapter.ts - neode-ui/src/composables/__tests__/archyContentAdapter.test.ts - neode-ui/src/api/filebrowser-client.ts - neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts - neode-ui/src/services/contextBroker.ts - neode-ui/src/types/aiui-protocol.ts - /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts - /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts autonomous: true requirements: [AIUI-03] must_haves: truths: - "AIUI's content grids show the node's real peer files, movies and owned/paid content instead of records regex-scraped out of the model's own prose (D-12)" - "AIUI's FilmGrid/SongGrid/NewsGrid components take zero code changes — only the data source behind their existing props changes (D-12)" - "IndeeHub and peer video reach the grids through the content + paid-unlock subsystem that already exists — invoices, X-Payment-Token, Range streaming — with no new payment rail (D-14)" - "Two content items with identical filename and size from different peers render as two distinct cards keyed by id, never merged; an item present both in this node's own library and in a peer share appears once per source (edge: AIUI-03 adjacency)" - "An empty content list renders the grid's empty state, not a spinner and not an error; a single item renders a one-card grid; an item with a null or absent description maps to an empty string, never the literal 'null' or 'undefined' (edge: AIUI-03 empty)" - "Content ordering is added_at descending with id ascending as the deterministic tiebreak, so items with equal timestamps come back in the same order on every call (edge: AIUI-03 ordering)" - "A content refresh arriving while an earlier one is still in flight is discarded by a request-id guard, so the grids never flip back to older data (edge: AIUI-03 concurrency)" - "No new streaming URL in this phase carries a credential in its query string — the leak is not propagated into the adapter" - "The pre-existing leak is actually closed, not merely avoided: filebrowser-client.ts's streamUrl returns a bare same-origin raw-file URL with no query component, and playback still works because the same-origin filebrowser cookie already travels on media subresource requests" artifacts: - path: "neode-ui/src/composables/archyContentAdapter.ts" provides: "ContentItem -> Film/Song/Podcast mapping; there is no shape overlap, so this is hand-written mapping logic" contains: "export function adaptContentItems" - path: "neode-ui/src/composables/__tests__/archyContentAdapter.test.ts" provides: "Fixture-pinned mapping including the adjacency, empty, ordering and concurrency edges" min_lines: 80 - path: "neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts" provides: "Regression pin that streamUrl emits no query component, so the JWT-in-URL leak cannot come back" contains: "streamUrl" key_links: - from: "neode-ui/src/services/contextBroker.ts" to: "neode-ui/src/composables/archyContentAdapter.ts" via: "content:push handler adapts content.* RPC records before they cross the iframe boundary" pattern: "adaptContentItems" - from: "/home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts" to: "/home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts" via: "setArchyContent() writes panelFilms/panelSongs/panelPodcasts directly, bypassing updatePanelFromText's regex path" pattern: "setArchyContent" --- Make AIUI's content surfaces real. Today they are fed by regex-parsing the model's own reply text (`updatePanelFromText` → `contentExtraction.ts`) against fixture catalogs that are themselves injected into the system prompt — the largest data bucket in AIUI is LLM-synthesized, not an API awaiting a base URL. D-12 keeps the design exactly and changes what fills it. The hard part is named in RESEARCH Pitfall 4: `content_server.rs::ContentItem` (`id`, `filename`, `mime_type`, `size_bytes`, `description`, `access`, `availability`, `added_at`) has **no shape overlap** with AIUI's `Film`/`Song`/`Podcast` (`posterUrl`, `coverUrl`, `sources[]` with `type: 'plex'|'nextcloud'|…`, `genres`, `runtime`, `director`). This is a hand-written adapter with fixture-pinned tests, not a pass-through. Two things this plan deliberately does not do. It does not revive `ContentPanel.vue` — that is verified dead code taking `ArchyAppsGrid`, `FavoritesGrid`, `DiscoverPanel`, `RecipeDetail` and `AppDetail` with it, and CONTEXT.md defers it explicitly. The live render tree is `ChatPage.vue` → `ContentGridView.vue` → the `*Grid` components, and that is what gets fed. And it does not attempt to fix AIUI's six dev-only Vite plugins: all of them are `configureServer`/`configurePreviewServer` only and are therefore absent from the static `dist/` a node serves, so TMDB posters, web search and RSS stay 404 on a node. Only the slice D-12 replaces gets a production answer; the rest stays explicitly deferred, and the plan says so rather than implying otherwise. This plan also closes the one credential-in-URL leak CONTEXT.md names by hand: `filebrowser-client.ts`'s `streamUrl` puts the filebrowser JWT in the query string, where it reaches browser history, `Referer` headers and access logs. CONTEXT.md calls it "the known leak to **fix** rather than propagate", so not reproducing it in new code is only half the instruction. The fix is small because the credential there is redundant: `login()` already sets that JWT as a `path=/` cookie on the page's own origin, and the browser attaches it to the same-origin media request without being asked. Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker, `setArchyContent` in AIUI, and a query-free `streamUrl`. None in this plan. **Edge-probe accounting for AIUI-03.** The probe surfaced **four** edges — adjacency, empty, ordering, concurrency — and all four are discharged here as covered truths tagged `(edge: AIUI-03 …)`. 13-07 carries three further truths with an AIUI-03 edge tag; those are **planner-authored** re-applications of the same edge kinds to the persisted music index, marked `— authored, not probe-surfaced` so the phase does not count one four-finding probe as seven. The reconciliation is in `13-VALIDATION.md` § Edge-Probe Reconciliation. Symbols created by **this plan**: **neode-ui** - `composables/archyContentAdapter.ts`: `export function adaptContentItems`, `adaptToFilm`, `adaptToSong`, `adaptToPodcast`, `classifyByMime`, `sortDeterministic`, `export type ArchyContentBundle`, `export interface ArchyContentItem` - `services/contextBroker.ts`: `handleContentRequest` (private), `pushContent` (private), `contentRequestSeq` (private field — the concurrency guard) - `types/aiui-protocol.ts`: `AIUIContentRequest`, `ArchyContentPush` **AIUI (`/home/archipelago/Projects/AIUI`, branch `development`)** - `composables/useArchy.ts`: `requestArchyContent` - `composables/useContentPanel.ts`: `setArchyContent`, `archyContentActive` (ref) Changed, not created: `neode-ui/src/api/filebrowser-client.ts` — `streamUrl`'s body only. No new export, no signature change; it still returns `Promise`, so every existing call site is untouched. Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid.vue`, `NewsGrid.vue`, `ContentGridView.vue`, and every `Film`/`Song`/`Podcast` type in `packages/core/src/types/content.ts`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-PATTERNS.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md Task 1: The adapter — hand-written mapping, fixture-pinned, edges decided neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts, neode-ui/src/api/filebrowser-client.ts, neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts - A video-mime `ContentItem` becomes a `Film` with `id` carried through, `title` derived from `filename` minus its extension, and exactly one entry in `sources[]` describing where it came from. - An audio-mime `ContentItem` becomes a `Song`; an image or document mime becomes neither and is excluded from all three buckets rather than mis-typed. - `m4a`, `aac`, `opus` and `wma` classify as audio — the four extensions `ShareModal.vue`'s mime map omits today. - An `access: 'Paid'` item maps with its price and a locked flag so the grid can render the paid state; it does **not** get a playable source URL until unlocked. - Two items with identical `filename` and `size_bytes` but different `id` produce two cards. - An empty input array produces empty `films`/`songs`/`podcasts` arrays — not `undefined`, not a thrown error. - A `null`/absent `description` maps to `''`; a `null` `added_at` sorts last rather than crashing the comparator. - Sorting is `added_at` descending, `id` ascending on ties — calling the adapter twice on the same input in a different array order yields identical output order. - `fileBrowserClient.streamUrl('/Music/x.m4a')` resolves to a same-origin raw-file URL carrying no query component and no credential anywhere in the string — the returned value contains no `?`, and does not contain the cookie's value. - `streamUrl` still awaits authentication before returning, so the cookie the media request depends on is guaranteed to be set by the time the caller assigns the URL to a media element. - `sanitizePath` traversal handling is unchanged by the fix — a path containing `..` is still resolved and never escapes root. - `/home/archipelago/Projects/AIUI/packages/core/src/types/content.ts` lines 7-70 — the exact target shapes: `Film` (line 7), `FilmSource` (23), `SongSource` (37), `Song` (44), `Podcast` (63). **This file is read, never modified** — D-12 keeps AIUI's design exactly. - `core/archipelago/src/content_server.rs` — `ContentItem` and `AccessControl` (`Free | PeersOnly | Paid`), the source shape being mapped from. - `core/archipelago/src/api/rpc/content.rs` — `content.list-mine`, `content.browse-peer`, `content.owned-list`, `content.preview-peer`, and the MIME auto-filing logic around line 668 (the classification precedent to stay consistent with). - `neode-ui/src/api/filebrowser-client.ts` in full — CONTEXT.md names this "the known leak to fix rather than propagate", and **this task fixes it**, so read the whole client, not just the leaking function. The four facts that make the fix small and safe: `login()` (lines 55-83) sets the filebrowser JWT as a **cookie** with `path=/` and `SameSite=Lax` on the page's own origin; `baseUrl` (line 43) is `window.location.origin + '/app/filebrowser'`, so a media element's request for it is **same-origin**; a same-origin subresource request carries that cookie automatically and `SameSite=Lax` does not restrict same-site subresources; and filebrowser's own auth reads the `auth` cookie, which is why its own web UI works without a query parameter. The credential in the query string is therefore redundant, not load-bearing. - `neode-ui/src/stores/cloud.ts` lines 117-119 and `neode-ui/src/components/cloud/MediaLightbox.vue` lines 138 and 202-203 — the call sites. They consume a URL string and are unaffected by dropping its query component; confirm that before changing anything. - `neode-ui/src/composables/__tests__/useFileType.test.ts` — the in-repo convention for a fixture-driven pure-function Vitest suite. - `13-RESEARCH.md` Pitfall 4 and Pitfall 5. Create `neode-ui/src/composables/archyContentAdapter.ts` exporting `adaptContentItems(items: ArchyContentItem[], opts: { source: 'own' | 'peer' | 'indeehub'; peerOnion?: string }): ArchyContentBundle` where `ArchyContentBundle` is `{ films: Film[]; songs: Song[]; podcasts: Podcast[] }` structurally matching AIUI's exported types (declare the minimal local interfaces rather than importing across repos — neode-ui does not depend on `@aiui/core`). `classifyByMime` decides the bucket from `mime_type` with an extension fallback for the cases the mime is wrong or generic. It must classify `audio/mp4`, `audio/aac`, `audio/opus`, `audio/x-ms-wma` and the `.m4a`/`.aac`/`.opus`/`.wma` extensions as audio — `ShareModal.vue`'s mime map omits exactly these four today, which is why such files currently share as `application/octet-stream`, never route to the audio player, and get auto-filed to `Documents` instead of `Music`. 13-11 fixes the share side; the adapter must not inherit the same blind spot. `adaptToFilm`/`adaptToSong`/`adaptToPodcast` carry `id` through unchanged as the card key (this is what makes the adjacency case correct: two peers sharing a byte-identical file are two rows, because they are two things the operator can act on separately). Derive `title` from `filename` with the extension stripped. Map `description ?? ''`. Build exactly one `sources[]` entry per item, with a `type` value that distinguishes this node's own file from a peer's file from IndeeHub — pin those three literal values in the test so a later refactor cannot quietly change what a grid badge means. For playback URLs: **do not build any URL containing a credential in its query string.** Own-node media resolves through the existing content endpoints (`/content/`), peer media through the existing Rust Range-streaming proxy (`/api/peer-content//`) — both of which already carry the page's session. Where a bare ` cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts cd neode-ui && npx vitest run src/api/__tests__/filebrowserStreamUrl.test.ts cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts cd neode-ui && npx vue-tsc --noEmit - `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts` exits 0 with a test per `` bullet - `grep -q 'export function adaptContentItems' neode-ui/src/composables/archyContentAdapter.ts` - `grep -ciE 'm4a|aac|opus|wma' neode-ui/src/composables/archyContentAdapter.ts` is ≥ 4 - `grep -vE '^\s*(//|\*|/\*)' neode-ui/src/composables/archyContentAdapter.ts | grep -cE '[?&](auth|token)='` returns 0 — no credential-bearing URL is produced by the adapter (comment lines stripped first, so prose in the file cannot self-invalidate the gate) - The test file contains an assertion that no adapter-produced URL carries a credential query parameter - `cd neode-ui && npx vitest run src/api/__tests__/filebrowserStreamUrl.test.ts` exits 0 — the pre-existing leak is closed and pinned - `grep -vE '^\s*(//|\*|/\*)' neode-ui/src/api/filebrowser-client.ts | grep -cF 'raw${safePath}?'` returns 0 — `streamUrl` appends no query component - `cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts` exits 0 — the lightbox's `streamUrl` consumer did not regress - `git -C /home/archipelago/Projects/AIUI diff --exit-code -- packages/core/src/types/content.ts packages/app/src/components/content/FilmGrid.vue packages/app/src/components/content/SongGrid.vue` exits 0 — D-12's "props unchanged" held - `cd neode-ui && npx vue-tsc --noEmit` exits 0 D-12's grid-source swap is rated costly in CONTEXT.md — the grids stay prop-driven and the source behind them is swappable, but every consumer is written against this mapping's field semantics. Flagged, not gated. Real `ContentItem` fixtures produce grid-ready `Film`/`Song`/`Podcast` records with stable ordering, correct empty/adjacency behaviour, no credential-bearing URLs, and no change to any AIUI grid component — and `filebrowser-client.ts`'s `streamUrl` returns a query-free same-origin URL, so the leak CONTEXT.md named is closed rather than merely unrepeated. Task 2: A content channel on the existing bridge, with a stale-response guard neode-ui/src/services/contextBroker.ts, neode-ui/src/types/aiui-protocol.ts - `neode-ui/src/services/contextBroker.ts` — the `handleMessage` switch (lines 71-84, now carrying 13-01's `chat:request` arm), `handleContextRequest` at 87, the ten `sanitize*` methods at 290-299, and `postToIframe` at 620. - `neode-ui/src/types/aiui-protocol.ts` — the unions 13-01 extended with `AIUIChatRequest`/`ArchyChatResponse`. - `neode-ui/src/services/__tests__/contextBroker.test.ts` — the suite that must stay green. - `core/archipelago/src/api/rpc/content.rs` — the exact `content.*` method names and their param shapes: `content.list-mine`, `content.browse-peer`, `content.owned-list`. - `neode-ui/src/stores/aiPermissions.ts` — the `media` and `files` categories; content push is gated on them. Add a content channel to the existing origin-checked bridge — a **single generic channel with a `kind` discriminator**, not one channel per content type. 13-11 adds music to it without touching this file again, which is what keeps the music track independent. In `aiui-protocol.ts` add `AIUIContentRequest { type: 'content:request'; id: string; kind: 'films' | 'songs' | 'podcasts' | 'all'; scope?: 'own' | 'peers' | 'owned' }` and `ArchyContentPush { type: 'content:push'; id: string; kind: string; films?: …; songs?: …; podcasts?: … }`, adding each to the appropriate union. In `contextBroker.ts` add a `case 'content:request'` arm and a private `handleContentRequest(id, kind, scope)` that: checks the `media`/`files` permission categories through the existing `useAIPermissionsStore` (this channel carries node data to the iframe, so it is a consent surface — unlike `chat:request`, whose authority is resolved node-side); calls the relevant `content.*` RPCs via `rpcClient.call`; runs the results through `adaptContentItems`; and posts a `content:push` back through the existing `postToIframe`. Add the concurrency guard: a private monotonically-increasing `contentRequestSeq`. Each `handleContentRequest` captures its sequence number before awaiting and discards its own result if a newer request has started in the meantime. Without this, a slow `content.browse-peer` landing after a fast `content.list-mine` flips the grid back to older data — the failure the AIUI-03 concurrency edge names. Do not add a second postMessage channel, do not relax `this.allowedOrigin`, and do not let AIUI supply the RPC method name or params — the iframe names a `kind`, the broker decides the call. Extend `contextBroker.test.ts` with a stale-response case asserting that an out-of-order resolution does not overwrite newer data. cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts cd neode-ui && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts cd neode-ui && npx vue-tsc --noEmit - `grep -q "content:request" neode-ui/src/services/contextBroker.ts` and `grep -q "adaptContentItems" neode-ui/src/services/contextBroker.ts` - `grep -q "contentRequestSeq" neode-ui/src/services/contextBroker.ts` — the stale-response guard exists - `contextBroker.test.ts` contains a test whose name mentions stale or out-of-order, and it passes - `cd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` both exit 0 - `grep -c "method: msg\.\|method: request\." neode-ui/src/services/contextBroker.ts` returns 0 — the iframe never names an RPC method - `cd neode-ui && npx vue-tsc --noEmit` exits 0 A `content:request` from the allowed origin, with the media/files categories granted, returns adapted grid records; an ungranted category returns a refusal; a stale in-flight response never overwrites newer data. Task 3: AIUI renders Archy content in the grids it already has /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts, /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts - `/home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` lines 1-45 — the module-level `panelFilms`/`panelSongs`/`panelPodcasts` refs and the mock imports, and `updatePanelFromText` at line 80 with its export list at 495-520. - `/home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` — the `__AIUI_EMBEDDED__` detection at lines 80-81 and the existing `archyBridge.requestContext(cat).then(...)` shape at line 134. **Mirror this; do not invent a third convention.** - `/home/archipelago/Projects/AIUI/packages/app/src/pages/ChatPage.vue` — the live render tree (`ContentGridView`). **Note `ContentPanel.vue` is dead code and must not be built through** (CONTEXT.md Deferred). - `/home/archipelago/Projects/AIUI/packages/app/src/composables/__tests__/` — the existing suite, including `contentExtraction.test.ts`, which must stay green. Work in `/home/archipelago/Projects/AIUI` on branch `development`. In `useContentPanel.ts` add `setArchyContent(bundle: { films?; songs?; podcasts? })`, which writes the module-level `panelFilms`/`panelSongs`/`panelPodcasts` refs directly, and an `archyContentActive` ref it sets true. Export both. Then guard `updatePanelFromText` so that when `archyContentActive` is true it does **not** overwrite the film/song/podcast buckets from regex-scraped model prose — the Archy-sourced grids are the source of truth for those three buckets when a node is supplying them. Leave the rest of `updatePanelFromText` (books, TV, images, places, magazine, code, recipes, news) untouched: those still have no Archy source and are outside D-12's slice. Do **not** delete `contentExtraction.ts` or its regex path. `13-PATTERNS.md` calls this a *partial* deprecation: the regex path stays for AIUI's non-Archy content and for standalone mode (D-17), and is bypassed only for the three Archy-sourced buckets. In `useArchy.ts` add `requestArchyContent(kind, scope)` following the existing `archyBridge.requestContext` shape, and call `setArchyContent` from its `content:push` handler. Register the handler alongside the existing bridge listeners; do not add a second `window.addEventListener('message')`. Do not touch `FilmGrid.vue`, `SongGrid.vue`, `NewsGrid.vue`, `ContentGridView.vue` or `packages/core/src/types/content.ts` — D-12 is explicit that only the data source changes. Do not revive `ContentPanel.vue`, `ArchyAppsGrid.vue`, `FavoritesGrid.vue`, `DiscoverPanel.vue`, `RecipeDetail.vue` or `AppDetail.vue`. Record honestly in the summary that TMDB posters, web search and RSS remain 404 on a node because their Vite plugins are dev-server-only — a `Film` adapted from a peer file has no `posterUrl` and the grid must render its existing no-artwork state rather than a broken image. Commit and push on `development`, staging explicitly by path. cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit cd /home/archipelago/Projects/AIUI && git diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts - `grep -q 'setArchyContent' /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` and it appears in the export list - `grep -q 'archyContentActive' /home/archipelago/Projects/AIUI/packages/app/src/composables/useContentPanel.ts` - `grep -q 'requestArchyContent' /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` - `grep -c 'ContentPanel' /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts` returns 0 — the dead path was not revived - `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/` exits 0 — no grid component changed - `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run` exits 0 (`contentExtraction.test.ts` still green — the regex path was guarded, not removed) - `cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit` exits 0 - The commit is pushed to `development` With a node supplying content, `FilmGrid` and `SongGrid` render real peer/owned/paid records through their unchanged props; with no node, AIUI's own regex path still works exactly as before. ## Trust Boundaries | Boundary | Description | |----------|-------------| | peer-supplied filenames and descriptions → the browser DOM | Peer-authored strings render as card titles and descriptions | | peer-supplied filenames and descriptions → the model context | Same strings will reach the assistant's context — D-10 territory, enforced in 13-12 | | broker → iframe | Node content crosses into AIUI; gated on the `media`/`files` grants | | media URL → ` - `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` all green - `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run && npx vue-tsc --noEmit` green - `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts` exits 0 - No adapter-produced URL carries a credential query parameter, and `filebrowserStreamUrl.test.ts` is green AIUI's existing grids show the node's real content, with no grid component or content type changed; the mapping is pinned by fixtures at its adjacency, empty, ordering and concurrency edges; the phase gains no new credential-in-URL leak and no new payment rail; and the one credential-in-URL leak that already existed is closed at its source, with its remaining long-lived-token residual named rather than glossed. Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-SUMMARY.md` when done