From d1dab810140d405df34945d357c73c8981eca873 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Wed, 10 Jun 2026 21:26:32 +0200 Subject: [PATCH] self improving the extensions api --- .../improve-api-from-conversations/SKILL.md | 46 ++++ .../improve-api-from-learnings/SKILL.md | 34 --- src/App.tsx | 26 +++ .../solutions/non-blocking-data-loader.md | 206 ++++++++++++++++++ src/electron/main.ts | 79 ++++++- src/electron/preload.ts | 6 + src/preload-api.ts | 10 + src/resources/nevermind-extension-api.d.ts | 25 ++- src/styles.css | 24 ++ src/ui.tsx | 20 +- 10 files changed, 432 insertions(+), 44 deletions(-) create mode 100644 .agents/skills/improve-api-from-conversations/SKILL.md delete mode 100644 .agents/skills/improve-api-from-learnings/SKILL.md create mode 100644 src/docs/solutions/non-blocking-data-loader.md diff --git a/.agents/skills/improve-api-from-conversations/SKILL.md b/.agents/skills/improve-api-from-conversations/SKILL.md new file mode 100644 index 0000000..4778fa6 --- /dev/null +++ b/.agents/skills/improve-api-from-conversations/SKILL.md @@ -0,0 +1,46 @@ +--- +name: improve-api-from-conversations +description: Use when improving Nevermind's extension API or builder workflow from recent local AI builder conversations. Review persisted aiChats and generated extension code, then draft actionable GitHub issues for API, host, or builder improvements. +--- + +# Improve API From Conversations + +Use this skill when the goal is to improve Nevermind itself from recent extension-building conversations, not when building a user extension. + +This skill intentionally uses local AI builder conversations as the primary evidence stream. Automatic learning exports can be useful background, but they are not the source of truth for product/API changes. + +Workflow: + +1. Explore the repo first: inspect `git status --short`, recent commits, current diffs, and relevant source before drawing conclusions. +2. Read recent local AI builder chats from Nevermind's persisted state as read-only evidence. Do not edit app-owned state files. +3. Identify chats with product/API friction: repeated retries, user complaints, generated extension runtime failures, confusing API usage, duplicate extensions, missing host primitives, blocked UI, broken loading states, or builder prompt failures. +4. Inspect the generated extension files referenced by those chats from the installed extensions directory. Compare the conversation symptoms with the actual extension code and host API behavior. +5. Check whether recent commits, current diffs, or source changes already address the root cause. Mark those findings as already addressed instead of rediscovering them as new work. +6. Group remaining evidence by root cause and classify each group as an extension API gap, host behavior gap, builder/tooling prompt gap, documentation/type-contract gap, or extension-authoring anti-pattern caused by missing primitives. +7. Prefer fixing API shape, host behavior, or typed contract guidance at the source instead of adding workaround instructions. +8. Draft one or more GitHub issue proposals. Split issues by independently shippable root cause; combine tightly coupled symptoms when one API change should solve them together. +9. Stop before creating issues unless the user explicitly asks. Provide copy-paste-ready issue bodies or `gh issue create` commands when helpful. + +Issue proposal format: + +- Title +- Problem / symptoms +- User-visible impact +- Evidence from conversations and generated extension code, summarized without raw private chat dumps +- Likely root cause in the extension API, host, or builder workflow +- Proposed API/host/builder changes +- Migration and backward-compatibility notes +- Acceptance criteria +- Suggested labels and priority +- Already-addressed related fixes, if any + +Rules: + +- Do not use this skill for user-extension authoring; use the normal extension-builder flow for that. +- Do not persist raw conversation contents, secrets, personal data, or local-only paths in issue bodies unless the path names stable product files or extension API surfaces. +- Treat local chats and installed generated extensions as private evidence. Summarize product/API friction rather than quoting long user conversations. +- Do not edit app-owned state while Nevermind is running; this workflow should only read state. +- Do not treat one-off chat details as product issues unless the user explicitly identifies them as broadly important or the API shape made the failure likely. +- When prompt changes are needed, tighten or replace existing guidance instead of appending parallel instructions. +- When API changes are needed, update `src/resources/nevermind-extension-api.d.ts` as the canonical contract in the implementation ticket. +- Before proposing a new issue, search current source, recent commits, and open diffs for the same root cause so already-shipped fixes are credited rather than duplicated. diff --git a/.agents/skills/improve-api-from-learnings/SKILL.md b/.agents/skills/improve-api-from-learnings/SKILL.md deleted file mode 100644 index d4882f6..0000000 --- a/.agents/skills/improve-api-from-learnings/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: improve-api-from-learnings -description: Use when improving Nevermind's extension API or builder workflow from accumulated learning traces. Export learnings first, then analyze the raw artifacts to propose API, prompt, or builder changes. ---- - -# Improve API From Learnings - -Use this skill when the goal is to improve Nevermind itself from past extension-building friction, not when building a user extension. - -Workflow: - -1. Run `mise exec pnpm -- pnpm learnings:export`. -2. Read the newest export bundle under `.tmp/learnings-export/`, and compare it with the previous bundle when one exists. -3. Before proposing fixes, check what is already being addressed: inspect `git status --short`, recent commits, current diffs, and source/history for the same error strings or root cause. -4. Treat `traces.json` as the main evidence stream. -5. Treat `learnings.md` and `learnings.json` as the current user-learning output, not the source of truth for product changes. -6. Group evidence by root cause and classify each group as new, still-unaddressed, or already-addressed-by-current-code/history. -7. Curate the active learning output: if a learning's root cause is fixed or too broad, remove it from active guidance or narrow it so future exports surface only still-useful issues. -8. Identify repeated unaddressed friction in how extensions are built: missing API primitives, confusing names, prompt failures, weak tool descriptions, repeated retries, or host gaps. -9. Prefer improving `src/resources/nevermind-extension-api.d.ts`, `src/electron/ai.ts`, the builder skill, or host behavior at the source instead of adding workaround instructions. -10. Make small reviewable changes and verify with `mise exec pnpm -- pnpm test` when code or packaged resources change. - -Rules: - -- Do not use this skill for user-extension authoring; use the normal extension-builder flow for that. -- Do not treat one-off chat details as product learnings; look for repeated or clearly generalizable friction. -- Do not rediscover old issues as new work: if current code, an open diff, or recent history already tackles the root cause, report it as already addressed and look for the next unaddressed issue. -- If an active learning is now addressed by source changes, prune or narrow it instead of letting it keep reappearing as product work. -- Do not edit app-owned learning store files while Nevermind is running; stop the app first or use a dedicated script/host path that safely rewrites the canonical store. -- If the latest export contains no new or still-unaddressed product issue, stop and say so instead of guessing a new API change. -- Do not bloat prompts with many new rules; prefer fixing API shape, tool descriptions, or host primitives. -- When prompt changes are needed, tighten or replace existing guidance instead of appending parallel instructions. -- When API changes are needed, update `src/resources/nevermind-extension-api.d.ts` as the canonical contract. -- Keep developer learnings grounded in raw exported traces, especially tool calls, validation cycles, and runtime failures. diff --git a/src/App.tsx b/src/App.tsx index f67cc14..bdc0e03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -547,6 +547,31 @@ export function App() { if (!current) return applyViewPatch(payload.patch) }) + const stopViewHydrate = window.nvm.onViewHydrate((payload) => { + markDebugPerformance('view.hydrate-event', { viewId: payload?.viewId, hasItems: Boolean(payload?.items), hasError: Boolean(payload?.error) }) + const current = extensionViewRef.current + if (payload.viewId && current?.id !== payload.viewId) return + if (!current) return + if (payload.error) { + const retryAction: CommandAction | undefined = payload.retry ? { + type: 'nativeAction', + title: 'Retry', + nativeAction: { kind: 'view-hydrate-retry', viewId: payload.viewId }, + } : undefined + const dismissAction: CommandAction = { type: 'popView', title: 'Dismiss' } + showExtensionView({ + type: 'preview', + title: 'Something went wrong', + content: `# Failed to load items\n\n\`\`\`\n${payload.error.message}\n\`\`\``, + actions: [...(retryAction ? [retryAction] : []), dismissAction], + ...(retryAction ? { actionPanel: { sections: [{ actions: [retryAction] }] } } : {}), + }, 'replace') + return + } + if (payload.items) { + applyViewPatch({ mode: 'replace', items: payload.items as any, isLoading: false }) + } + }) const stopAi = window.nvm.onAiChatEvent((event) => { markDebugPerformance(`ai.event.${event.type}`, { chatId: event.chatId, textLength: event.text?.length || 0, label: event.label }) if (event.type === 'debug') window.nvm.log('debug', `Nevermind AI: ${event.label || ''}`, event.data) @@ -561,6 +586,7 @@ export function App() { stopRootItems() stopOpenActionView() stopViewPatch() + stopViewHydrate() stopAi() } }, []) diff --git a/src/docs/solutions/non-blocking-data-loader.md b/src/docs/solutions/non-blocking-data-loader.md new file mode 100644 index 0000000..f8b359c --- /dev/null +++ b/src/docs/solutions/non-blocking-data-loader.md @@ -0,0 +1,206 @@ +# Non-Blocking Data Loader API + +**Status: Implemented** (2026-06-10) + +## Problem + +Extension `command.run(ctx)` blocks the palette UI until it returns. There is no way to paint a +skeleton first and fill in data later. The `refresh: { immediate: true }` escape hatch is fragile: + +- Re-invokes the entire `run()` function a second time. +- Races between the initial paint and the refresh IPC. +- Discards non-item view metadata (`isLoading`, `emptyView`) during refresh. +- Requires the extension to bifurcate logic with `ctx.launch?.refresh`. + +Extension authors cannot build progress UIs, streaming UIs, or lazy-loaded lists without +contending with these races. + +## Goal + +Make it **impossible to block the UI** from an extension. The common case (fetch → list) should +be one extra method call. The host owns the loading/error/empty lifecycle. + +## Design + +### Extension API + +```ts +// New namespace: ctx.data +type ExtensionData = { + /** + * Declare items that resolve asynchronously. The host: + * 1. Paints the skeleton immediately (spinner deferred 200ms). + * 2. Calls loader() in the background. + * 3. Patches items when resolved. + * 4. Renders emptyView when loader returns []. + * 5. Renders an error view when loader throws. + */ + loader( + fn: () => Promise, + options?: { retry?: boolean } + ): ExtensionDataLoaderHandle +} + +type ExtensionDataLoaderHandle = { + /** Opaque handle; the host replaces this with items after resolution. */ + _loader: true +} +``` + +`ctx.data.loader(fn)` returns an opaque handle. The host recognizes it during +`normalizeViewItems` / `normalizeExtensionView` and sets up the async pipeline. + +### Required `emptyView` + +Views that use `ctx.data.loader()` must declare `emptyView`. The skeleton paints the `emptyView` +content immediately (behind the deferred spinner), so there's never a flash of nothing: + +```ts +commands: [{ + id: 'show-prs', + title: 'Show My Pull Requests', + async run(ctx) { + return ctx.ui.list({ + title: 'My Pull Requests', + emptyView: { title: 'No open PRs', subtitle: 'You have no open pull requests' }, + items: ctx.data.loader(async () => { + const result = await ctx.desktop.shell.script('gh search prs …') + return JSON.parse(result.stdout).map(toItem) + }), + }) + } +}] +``` + +`run()` returns synchronously (the view object is constructed before any `await`). The host +paints the skeleton, then invokes the loader. The extension does not manage timing or loading +state. + +### Spinner debounce + +The renderer defers the loading spinner by 200ms. If the loader resolves before the threshold, +the spinner is never shown. This prevents flicker on fast data. + +``` +t=0 Paint skeleton + emptyView (no spinner) +t=120 Loader resolves → patch items, spinner never appeared +``` + +``` +t=0 Paint skeleton + emptyView +t=200 Spinner fades in +t=850 Loader resolves → patch items, spinner dismissed +``` + +The threshold is host-owned, not configurable by the extension (to guarantee consistency). + +### Error state + +When the loader throws, the host renders a standard error view. If `options.retry` is true, the +view includes a "Retry" button that re-runs the loader. + +``` +t=0 Paint skeleton +t=200 Spinner appears +t=500 Loader throws → error view with message, optional retry button +``` + +### Progress views stay imperative + +`ctx.data.loader()` covers the fetch → list case. For multi-step progress (media compression), +`ctx.paint()` remains available as a separate follow-up — out of scope for this spec. + +## Execution flow + +### Host side (main.ts) + +``` +run() returns view with items = LoaderHandle + │ + ├─→ normalizeViewItems: detect LoaderHandle, strip it, set items: [] + ├─→ normalizeView: inject refresh-like handle for the loader + ├─→ send view to renderer IMMEDIATELY (do not await loader) + │ + └─→ spawn background job: + loader() + .then(items => send view:hydrate { viewId, items, isLoading: false }) + .catch(err => send view:hydrate { viewId, error: message, retry: bool }) +``` + +Key: the IPC result for `runViewAction` / `execute` returns the skeleton view **before** the +loader resolves. The loader result arrives later via a `view:hydrate` push message. + +### IPC + +New channel: `view:hydrate` + +```ts +// preload.ts +onViewHydrate: (callback) => { + ipcRenderer.on('view:hydrate', listener) +} + +// main.ts → renderer +paletteWindow.win?.webContents.send('view:hydrate', { + viewId: string, + items?: ExtensionItem[], // present on success + isLoading?: false, // always false when items present + emptyView?: …, // carried from initial view + error?: { message: string }, // present on failure + retry?: boolean, // whether to show a retry button +}) +``` + +### Renderer side (App.tsx) + +```ts +useEffect(() => { + return window.nvm.onViewHydrate((payload) => { + if (payload.viewId !== extensionViewRef.current?.id) return + if (payload.error) { + // Replace view with error state + showExtensionView(errorView(payload.error, payload.retry), 'replace') + return + } + applyViewPatch({ + mode: 'replace', + items: payload.items, + isLoading: false, + }) + }) +}, []) +``` + +## What happens to `refresh: { immediate: true }`? + +It stays working for backward compatibility but is deprecated. The `ctx.data.loader()` host +pipeline is implemented on top of the same `view:hydrate` IPC rather than the fragile +double-`run()` pattern. Existing extensions using `refresh` continue to work unchanged. + +## Implementation steps + +1. **Type definitions** — add `ctx.data` to `ExtensionContext`, add `ExtensionDataLoaderHandle` type to `nevermind-extension-api.d.ts`. + +2. **`createExtensionContext`** — add `data: { loader(fn, opts) }` namespace. + +3. **`normalizeViewItems`** — detect `LoaderHandle`, record the loader against a view id, return `[]` for items. + +4. **Loader registry** — `Map`. Register on initial view send, clean up on view close/pop. + +5. **`executeViewActionResult` / `executeViewAction`** — when a view has a pending loader, send the skeleton immediately via `action:view-open`, then spawn the loader in background. The await on `run()` is still required (it returns fast since `ctx.data.loader()` is synchronous), but the loader runs after. + +6. **IPC** — add `view:hydrate` channel to preload, main sender, renderer listener. + +7. **Spinner delay** — in `ExtensionViewRenderer`, when `items.length === 0 && emptyView` and no error, start a 200ms timer before showing spinner. Cancel on hydrate. + +8. **Error rendering** — host generates error view from `payload.error`, includes optional retry button that re-registers and re-runs the loader. + +9. **`emptyView` required** — at runtime, if a view has `ctx.data.loader()` items but no `emptyView`, warn and use a default empty state. TypeScript types should mark it as required in the relevant overload. + +10. **Tests** — new unit test for loader lifecycle, hydration IPC, spinner debounce, error/retry, empty state. + +## Risks + +- **`run()` still blocks on synchronous work.** If the extension does sync work before returning the skeleton (e.g., `fs.readFileSync` on a huge file), the UI blocks. The fix for this is `ctx.paint()` (out of scope for this spec). +- **Multiple loaders per view.** Not supported in v1. If an extension calls `ctx.data.loader()` twice in one view, the second call replaces the first. +- **Loader surviving navigation.** If the user navigates away before the loader resolves, the hydration message is dropped (viewId mismatch). The background work completes but results are discarded. diff --git a/src/electron/main.ts b/src/electron/main.ts index 50070a7..fe2f990 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -151,6 +151,34 @@ let learningStore: LocalLearningStore | null = null const learningReviewJobs = new Map>() let activeAiChatId: string | undefined const draftAiChats = new Map() +const viewLoaderRegistry = new Map Promise; retry: boolean; entry: any }>() + +function isLoaderHandle(value: unknown): value is { _loader: true; _fn: () => Promise; _retry: boolean } { + return Boolean(value && typeof value === 'object' && '_loader' in value) +} + +function spawnViewLoader(viewId: string, loader: { fn: () => Promise; retry: boolean; entry: any }) { + const send = (payload: Record) => paletteWindow.win?.webContents.send('view:hydrate', { viewId, ...payload }) + loader.fn() + .then((items) => { + viewLoaderRegistry.delete(viewId) + const normalized = Array.isArray(items) ? normalizeViewItems(items, loader.entry) : [] + send({ items: normalized, isLoading: false }) + }) + .catch((error) => { + viewLoaderRegistry.delete(viewId) + const message = error instanceof Error ? error.message : String(error) + send({ error: { message }, retry: loader.retry }) + logWarn('view.loader.failed', { viewId, error: message }, { source: 'host', scope: 'extension' }) + }) +} + +function spawnPendingViewLoaders(result: any) { + const viewId = result?.view?.id + if (viewId && viewLoaderRegistry.has(viewId)) { + spawnViewLoader(viewId, viewLoaderRegistry.get(viewId)!) + } +} let didRunQuitCleanup = false let userState: AnyRecord = { recents: {}, @@ -1641,6 +1669,7 @@ async function executeActionForIpc(action) { trustedAction = resolveRootActionForIpc(action) const result = normalizeHostViewResult(await measureDebugPerformance('action.execute', { action: summarizeDebugValue(trustedAction), alwaysLog: true }, () => executeAction(trustedAction))) structuredClone(result) + spawnPendingViewLoaders(result) return result } catch (error) { if (trustedAction?.kind === 'extension-command') { @@ -1699,8 +1728,24 @@ function normalizeExtensionView(result, entry) { function normalizeView(view, entry) { const actions = normalizeViewActions(view.actions, entry) const webviewPermissions = view.type === 'webview' ? filterWebviewPermissionsForExtension(entry?.extension, view.webviewPermissions) : view.webviewPermissions + const viewId = view.id || `view:${crypto.randomUUID()}` + + // Detect and register data loader before stripping it from items + const loaderHandle = isLoaderHandle(view.items) ? view.items : undefined + if (loaderHandle) { + viewLoaderRegistry.set(viewId, { fn: loaderHandle._fn, retry: loaderHandle._retry, entry }) + } + + const items = normalizeViewItems(loaderHandle ? [] : view.items, entry) + const sections = Array.isArray(view.sections) ? view.sections.map((section) => ({ ...section, items: normalizeViewItems(section.items, entry) })) : view.sections + + const emptyView = loaderHandle && !view.emptyView + ? { title: 'No items', subtitle: '' } + : view.emptyView + return { ...view, + id: viewId, ...(webviewPermissions === undefined ? {} : { webviewPermissions }), actions, actionPanel: normalizeActionPanel(view.actionPanel, actions, entry), @@ -1708,8 +1753,10 @@ function normalizeView(view, entry) { submitAction: normalizeViewAction(view.submitAction, entry), searchAccessory: view.searchAccessory ? { ...view.searchAccessory, onChange: normalizeViewAction(view.searchAccessory.onChange, entry) } : view.searchAccessory, refresh: registerViewRefreshForRenderer(view.refresh, entry, view), - items: normalizeViewItems(view.items, entry), - sections: Array.isArray(view.sections) ? view.sections.map((section) => ({ ...section, items: normalizeViewItems(section.items, entry) })) : view.sections, + items, + sections, + ...(emptyView ? { emptyView } : {}), + ...(loaderHandle ? { isLoading: true } : {}), } } @@ -1722,6 +1769,7 @@ function persistentActionForRef(action, entry) { } function normalizeViewItems(items, entry) { + if (isLoaderHandle(items)) return [] return Array.isArray(items) ? items.map((item) => { const itemActions = normalizeViewActions(item.actions, entry) const primaryAction = normalizeViewAction(item.primaryAction || item.action, entry) @@ -1867,7 +1915,7 @@ async function executeHostRefreshAction(record, launchContext?: any) { if (!record.entry?.command || typeof record.entry.command.run !== 'function') return { skipped: true } const result = await record.entry.command.run(createExtensionContext(record.entry.extension, record.entry.command, launchContext)) const view = result?.type ? result : result?.view?.type ? result.view : null - if (view?.items) return { patch: { mode: record.mode || 'replace', items: normalizeViewItems(view.items, record.entry) } } + if (view?.items && !isLoaderHandle(view.items) && Array.isArray(view.items) && view.items.length > 0) return { patch: { mode: record.mode || 'replace', items: normalizeViewItems(view.items, record.entry) } } return executeViewActionResult(result, record.entry, launchContext) } @@ -1889,6 +1937,7 @@ async function refreshViewForIpc(input: any = {}) { try { const result = normalizeHostViewResult(await executeHostRefreshAction(record, { refresh: true, reason: 'refresh', startedAt: Date.now() })) structuredClone(result) + spawnPendingViewLoaders(result) record.failureCount = 0 record.backoffUntil = undefined return result @@ -1912,6 +1961,7 @@ async function executeViewActionForIpc(action) { trustedAction = resolveViewActionForIpc(action) const result = normalizeHostViewResult(await measureDebugPerformance('view-action.execute', { action: summarizeDebugValue(trustedAction), alwaysLog: true }, () => executeViewAction(trustedAction))) structuredClone(result) + spawnPendingViewLoaders(result) return result } catch (error) { const record = trustedAction?.type === 'runExtensionAction' ? extensionActionHandlers.get(trustedAction.handlerId) : null @@ -2008,8 +2058,16 @@ function requestQuitApp(reason = 'action') { async function executeViewAction(action, launchContext?: any) { switch (action?.type) { - case 'nativeAction': + case 'nativeAction': { + const native = action.nativeAction as { kind?: string; viewId?: string } | undefined + if (native?.kind === 'view-hydrate-retry' && native.viewId) { + const loader = viewLoaderRegistry.get(native.viewId) + if (loader) spawnViewLoader(native.viewId, loader) + // Return a loading skeleton so the renderer replaces the error view + return { view: { type: 'list' as const, id: native.viewId, title: 'Loading…', isLoading: true, items: [] }, navigation: 'replace' as const } + } return executeAction(action.nativeAction, { keepPaletteOpen: true }) + } case 'openPath': runInBackground(() => shell.openPath(action.path)) break @@ -3936,6 +3994,11 @@ function createExtensionContext(extension, command, launchContext?: any) { views: createExtensionViewsApi(extension, command), updates: canUseUpdates ? { getState: () => updatesStateSnapshot() } : undefined, state: {}, + data: { + loader(fn, options: any = {}) { + return { _loader: true, _fn: fn, _retry: Boolean(options.retry) } + }, + }, ai: canUseAi ? createExtensionAi(extension) : undefined, aiBuilder: createAiBuilderApi(extension), extensions: { ownership: canManageExtensionOwnership ? createExtensionOwnershipApi(extension) : undefined }, @@ -4044,7 +4107,7 @@ function createExtensionViewsApi(extension, command) { if (!checkRefreshBurst(extension)) return { skipped: true } const result = await command.run(innerCtx) const view = result?.type ? result : result?.view?.type ? result.view : null - if (view?.items) return { patch: { mode: 'replace', items: view.items } } + if (view?.items && !isLoaderHandle(view.items) && Array.isArray(view.items) && view.items.length > 0) return { patch: { mode: 'replace', items: view.items } } if (view) return { view, navigation: 'replace' } return result }, @@ -5376,6 +5439,12 @@ app.whenReady().then(async () => { logWarn, loggerDebug, }) + + ipcMain.handle('view:hydrate:retry', async (_event, viewId: string) => { + const loader = viewLoaderRegistry.get(viewId) + if (!loader) return + spawnViewLoader(viewId, loader) + }) }) app.on('activate', () => paletteWindow.showPalette()) diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 1cd0e5b..4bd3f47 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -128,6 +128,12 @@ const api: NevermindApi = { ipcRenderer.on('view:patch', listener) return () => ipcRenderer.removeListener('view:patch', listener) }, + onViewHydrate: (callback) => { + const listener = (_event: IpcRendererEvent, payload: Parameters[0] extends (payload: infer Payload) => void ? Payload : never) => callback(payload) + ipcRenderer.on('view:hydrate', listener) + return () => ipcRenderer.removeListener('view:hydrate', listener) + }, + retryViewLoader: (viewId) => invokeMeasured('view:hydrate:retry', viewId), } contextBridge.exposeInMainWorld('nvm', api) diff --git a/src/preload-api.ts b/src/preload-api.ts index a72cb1e..dc03248 100644 --- a/src/preload-api.ts +++ b/src/preload-api.ts @@ -92,6 +92,14 @@ export type AiChatEvent = { data?: unknown } +export type ViewHydratePayload = { + viewId: string + items?: CommandView['items'] + isLoading?: false + error?: { message: string } + retry?: boolean +} + export type NevermindApi = { search: (query: string, options?: { clipboardOnly?: boolean }) => Promise execute: (action: RootAction) => Promise<{ view?: CommandView }> @@ -143,4 +151,6 @@ export type NevermindApi = { closeExtensionWindow: () => Promise onExtensionWindowView: (callback: (payload: { id: string; view: CommandView; options?: Record }) => void) => () => void onViewPatch: (callback: (payload: { viewId?: string; patch: CommandViewPatch }) => void) => () => void + onViewHydrate: (callback: (payload: ViewHydratePayload) => void) => () => void + retryViewLoader: (viewId: string) => Promise } diff --git a/src/resources/nevermind-extension-api.d.ts b/src/resources/nevermind-extension-api.d.ts index 807d141..59a2faa 100644 --- a/src/resources/nevermind-extension-api.d.ts +++ b/src/resources/nevermind-extension-api.d.ts @@ -371,7 +371,8 @@ export type ExtensionView = { showDeviceSwitcher?: boolean muted?: boolean controls?: boolean - items?: ExtensionItem[] + /** View items or a lazy loader handle from `ctx.data.loader(fn)`. When a loader handle is used, the host owns the loading lifecycle and `emptyView` is required. */ + items?: ExtensionItem[] | ExtensionDataLoaderHandle sections?: ExtensionItemSection[] isLoading?: boolean emptyView?: { title?: string; subtitle?: string } @@ -580,6 +581,26 @@ export type ExtensionAiStream = { abort(): void } +/** Opaque handle returned by `ctx.data.loader()`. The host replaces it with resolved items after the loader completes. */ +export type ExtensionDataLoaderHandle = { _loader: true } + +/** Lazy data fetching. The host owns the loading/error/empty lifecycle. */ +export type ExtensionData = { + /** + * Declare items that resolve asynchronously. The view skeleton paints immediately; + * the loader runs in the background. When it resolves, items are patched into the + * already-visible view. The host shows a deferred spinner (200ms) while the loader + * runs and renders `view.emptyView` content behind it. On failure, the host shows + * an error view with an optional retry button. + * + * The returned handle is opaque to extensions. Assign it to `view.items`. + */ + loader( + fn: () => Promise, + options?: { retry?: boolean } + ): ExtensionDataLoaderHandle +} + export type ExtensionAi = { /** Easy one-shot AI call. Quota-limited per extension; declare `ai` permission. Smart/fast routes are configured by the backend admin. */ (prompt: string, model?: ExtensionAiModel): Promise @@ -923,6 +944,8 @@ export type ExtensionContext = { /** App update state. Present only with the `updates` permission. Pair with `ctx.actions.updates.*`. */ updates?: { getState(): ExtensionUpdateState } state: Record + /** Lazy data fetching. Use `ctx.data.loader(fn)` so the view skeleton paints immediately while the loader runs in the background. */ + data: ExtensionData ai?: ExtensionAi aiBuilder?: ExtensionAiBuilder extensions: { ownership?: ExtensionOwnership } diff --git a/src/styles.css b/src/styles.css index 4a59416..4a90cc7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1473,6 +1473,30 @@ kbd { opacity: 1; } +.loadingSpinner { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + animation: loadingSpinnerFadeIn 0.15s ease-out; +} + +.spinnerIcon { + animation: spinnerRotate 1s linear infinite; + opacity: 0.4; + color: var(--color-text-secondary); +} + +@keyframes loadingSpinnerFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes spinnerRotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .card.loadingBorder::after { content: ''; position: absolute; diff --git a/src/ui.tsx b/src/ui.tsx index dfd43a7..68c25e5 100644 --- a/src/ui.tsx +++ b/src/ui.tsx @@ -1,6 +1,6 @@ -import React, { type ReactNode } from 'react' +import React, { useEffect, useState, type ReactNode } from 'react' import { Command } from 'cmdk' -import { Folder } from 'lucide-react' +import { Folder, Loader2 } from 'lucide-react' import ReactMarkdown, { defaultUrlTransform } from 'react-markdown' import remarkGfm from 'remark-gfm' import type { CommandImage } from './model' @@ -190,14 +190,26 @@ function normalizedSections(items?: T[], sections?: ItemSection[]) { return sections?.length ? sections : [{ items: items || [] }] } +function LoadingSpinner({ delayMs = 200 }: { delayMs?: number }) { + const [visible, setVisible] = useState(false) + useEffect(() => { + const timer = setTimeout(() => setVisible(true), delayMs) + return () => clearTimeout(timer) + }, [delayMs]) + if (!visible) return null + return
+} + export function ListView({ items, sections, renderItem, empty, subtitle, isLoading, pagination }: ListViewProps) { const visibleSections = normalizedSections(items, sections).filter((section) => section.items.length > 0) - return <>{subtitle ?
{subtitle}
: null}{visibleSections.length > 0 ? visibleSections.map((section, index) =>
{section.title ?
{section.title}
: null}{section.subtitle ?
{section.subtitle}
: null}{section.items.map(renderItem)}
) : empty}{pagination} + const hasItems = visibleSections.length > 0 + return <>{subtitle ?
{subtitle}
: null}{hasItems ? visibleSections.map((section, index) =>
{section.title ?
{section.title}
: null}{section.subtitle ?
{section.subtitle}
: null}{section.items.map(renderItem)}
) : isLoading ? : empty}{pagination} } export function GridView({ items, sections, renderItem, empty, subtitle, layout = 'square', style, isLoading, pagination }: GridViewProps) { const visibleSections = normalizedSections(items, sections).filter((section) => section.items.length > 0) - return
{subtitle ?
{subtitle}
: null}{visibleSections.length > 0 ? visibleSections.map((section, index) =>
{section.title ?
{section.title}
: null}{section.subtitle ?
{section.subtitle}
: null}
{section.items.map(renderItem)}
) : empty}{pagination}
+ const hasItems = visibleSections.length > 0 + return
{subtitle ?
{subtitle}
: null}{hasItems ? visibleSections.map((section, index) =>
{section.title ?
{section.title}
: null}{section.subtitle ?
{section.subtitle}
: null}
{section.items.map(renderItem)}
) : isLoading ? : empty}{pagination}
} export function ChatView({ messages, isBusy, input, messagesRef }: ChatViewProps) {