diff --git a/packages/core/src/__tests__/inspector.test.ts b/packages/core/src/__tests__/inspector.test.ts index 5ef85db..f8a341b 100644 --- a/packages/core/src/__tests__/inspector.test.ts +++ b/packages/core/src/__tests__/inspector.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { createAskableContext } from '../index.js'; +import { createAskableContext, createAskableSource } from '../index.js'; import { createAskableInspector } from '../inspector.js'; function makeEl(meta: object | string, text = ''): HTMLElement { @@ -10,6 +10,12 @@ function makeEl(meta: object | string, text = ''): HTMLElement { return el; } +async function waitForInspectorPreview() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe('createAskableInspector', () => { const elements: HTMLElement[] = []; @@ -109,6 +115,35 @@ describe('createAskableInspector', () => { ctx.destroy(); }); + it('can show resolved source-backed prompt context in the panel', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableSource({ + kind: 'collection', + describe: 'Accounts matching filters', + state: { filter: 'enterprise' }, + modes: { + all: { total: 2, companies: ['Acme Corp', 'Beta Labs'] }, + }, + })); + const inspector = createAskableInspector(ctx, { + sourcePreview: { + sourceMode: 'all', + sourceLabel: 'Resolved sources', + }, + }); + + await waitForInspectorPreview(); + + const panel = document.getElementById('askable-inspector')!; + expect(panel.textContent).toContain('Resolved sources'); + expect(panel.textContent).toContain('accounts'); + expect(panel.textContent).toContain('Acme Corp'); + expect(panel.textContent).toContain('enterprise'); + + inspector.destroy(); + ctx.destroy(); + }); + it('updates context sources when registrations change', () => { const ctx = createAskableContext(); const inspector = createAskableInspector(ctx); @@ -351,6 +386,39 @@ describe('createAskableInspector', () => { ctx.destroy(); }); + it('copies source-backed prompt context when sourcePreview is enabled', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const originalClipboard = navigator.clipboard; + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableSource({ + data: { total: 12, company: 'Acme Corp' }, + })); + const inspector = createAskableInspector(ctx, { sourcePreview: true }); + + await waitForInspectorPreview(); + + document + .querySelector('[data-askable-inspector-copy]')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Context sources')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Acme Corp')); + + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + configurable: true, + }); + inspector.destroy(); + ctx.destroy(); + }); + it('can hide built-in interaction test tools', () => { const ctx = createAskableContext(); const inspector = createAskableInspector(ctx, { tools: false }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83d9e7b..34c4b48 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export type { AskableInspectorHandle, AskableInspectorOptions, AskableInspectorPosition, + AskableInspectorSourcePreviewOptions, } from './inspector.js'; export type { AskableRegionCaptureHandle, diff --git a/packages/core/src/inspector.ts b/packages/core/src/inspector.ts index 26c3d32..f5ae45b 100644 --- a/packages/core/src/inspector.ts +++ b/packages/core/src/inspector.ts @@ -1,7 +1,11 @@ import type { + AskableAsyncPromptContextOptions, AskableContext, AskableContextSourceInfo, AskableFocus, + AskableContextSourceInclude, + AskableContextSourceErrorMode, + AskableContextSourceMode, AskablePromptContextOptions, } from './types.js'; import { createAskableRegionCapture } from './capture.js'; @@ -11,6 +15,17 @@ import type { AskableTextSelectionCaptureHandle } from './selection.js'; export type AskableInspectorPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; +export interface AskableInspectorSourcePreviewOptions { + /** Sources to include in the inspector prompt preview. Defaults to all registered sources. */ + sources?: 'all' | AskableContextSourceInclude[]; + /** Default source mode when a source request omits mode. Defaults to "summary". */ + sourceMode?: AskableContextSourceMode; + /** Heading used in the prompt preview. Defaults to "Context sources". */ + sourceLabel?: string; + /** How failed sources are shown in the inspector preview. Defaults to "include". */ + sourceErrorMode?: AskableContextSourceErrorMode; +} + export interface AskableInspectorOptions { /** * Where to anchor the inspector panel. @@ -21,6 +36,12 @@ export interface AskableInspectorOptions { * Serialization options passed to toPromptContext() for the output preview. */ promptOptions?: AskablePromptContextOptions; + /** + * Include resolved app-owned sources in the prompt preview and Copy output. + * Pass true to include all registered sources with default source options. + * @default false + */ + sourcePreview?: boolean | AskableInspectorSourcePreviewOptions; /** * Highlight the focused element with an outline. * @default true @@ -60,8 +81,22 @@ function renderMeta(meta: Record | string): string { return `{\n${lines}\n}`; } +function buildPromptContextHTML(promptContext: string): string { + return ` +
+ Prompt context
+
${escapeHtml(promptContext)}
+
+ `; +} + function buildFocusHTML(focus: AskableFocus | null, promptContext: string): string { - if (!focus) return '
No element focused
'; + if (!focus) { + return ` +
No element focused
+ ${buildPromptContextHTML(promptContext)} + `; + } const el = focus.element; const tag = el ? el.tagName.toLowerCase() : null; const id = el?.id ? `#${escapeHtml(el.id)}` : ''; @@ -87,10 +122,7 @@ function buildFocusHTML(focus: AskableFocus | null, promptContext: string): stri "${escapeHtml(focus.text.slice(0, 120))}${focus.text.length > 120 ? '…' : ''}" ` : ''} -
- Prompt context
-
${escapeHtml(promptContext)}
-
+ ${buildPromptContextHTML(promptContext)} `; } @@ -132,6 +164,24 @@ function buildPanelHTML( return `${buildFocusHTML(focus, promptContext)}${buildSourcesHTML(sources)}`; } +function resolveSourcePreviewOptions( + promptOptions: AskablePromptContextOptions | undefined, + sourcePreview: true | AskableInspectorSourcePreviewOptions, +): AskableAsyncPromptContextOptions { + if (sourcePreview === true) { + return { + ...promptOptions, + sources: 'all', + }; + } + + return { + ...promptOptions, + ...sourcePreview, + sources: sourcePreview.sources ?? 'all', + }; +} + function clampPanelPosition(panel: HTMLElement, left: number, top: number): { left: number; top: number } { const rect = panel.getBoundingClientRect(); const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; @@ -225,6 +275,7 @@ export function createAskableInspector( const { position = 'bottom-right', promptOptions, + sourcePreview = false, highlight = true, tools = true, } = options; @@ -293,8 +344,10 @@ export function createAskableInspector( document.body.appendChild(panel); + let destroyed = false; let highlightedEl: HTMLElement | null = null; let latestPromptContext = ''; + let updateVersion = 0; function clearHighlight() { if (highlightedEl) { @@ -314,14 +367,35 @@ export function createAskableInspector( highlightedEl = el; } - function update(focus: AskableFocus | null) { - const promptContext = ctx.toPromptContext(promptOptions); + function renderFocusState(focus: AskableFocus | null, promptContext: string) { latestPromptContext = promptContext; body.innerHTML = buildPanelHTML(focus, promptContext, ctx.listSources()); if (focus?.element?.isConnected) applyHighlight(focus.element); else clearHighlight(); } + async function updateSourcePreview(focus: AskableFocus | null, version: number) { + if (!sourcePreview) return; + try { + const promptContext = await ctx.toPromptContextAsync( + resolveSourcePreviewOptions(promptOptions, sourcePreview), + ); + if (destroyed || version !== updateVersion) return; + renderFocusState(focus, promptContext); + } catch { + if (destroyed || version !== updateVersion) return; + const fallback = `${ctx.toPromptContext(promptOptions)}\n\nContext sources:\nContext source unavailable.`; + renderFocusState(focus, fallback); + } + } + + function update(focus: AskableFocus | null) { + const version = ++updateVersion; + const promptContext = ctx.toPromptContext(promptOptions); + renderFocusState(focus, promptContext); + if (sourcePreview) void updateSourcePreview(focus, version); + } + // Initial render update(ctx.getFocus()); @@ -332,7 +406,6 @@ export function createAskableInspector( ctx.on('clear', clearHandler); ctx.on('sourcechange', sourceHandler); - let destroyed = false; let activeTool: InspectorToolHandle | null = null; let activeToolName: string | null = null; let dragging: { diff --git a/packages/react/README.md b/packages/react/README.md index ab6360a..27ca703 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -123,13 +123,15 @@ import { AskableInspector } from '@askable-ui/react'; ``` Pass the same `events`, `name`, `viewport`, or `ctx` that your React app uses for `useAskable()` when the inspector should follow the same context. +Set `sourcePreview` when the dev panel should resolve registered source data in +the prompt preview and Copy output. ```tsx function DevInspector() { useAskable({ events: ['click'] }); return process.env.NODE_ENV === 'development' - ? + ? : null; } ``` diff --git a/packages/react/src/AskableInspector.tsx b/packages/react/src/AskableInspector.tsx index b28349f..bb9e727 100644 --- a/packages/react/src/AskableInspector.tsx +++ b/packages/react/src/AskableInspector.tsx @@ -34,15 +34,18 @@ export function AskableInspector({ }: AskableInspectorProps) { const { ctx } = useAskable({ ctx: providedCtx, name, events, viewport }); const promptOptionsKey = JSON.stringify(inspectorOptions.promptOptions ?? null); + const sourcePreviewKey = JSON.stringify(inspectorOptions.sourcePreview ?? null); const stableInspectorOptions = useMemo( () => ({ position: inspectorOptions.position, highlight: inspectorOptions.highlight, + tools: inspectorOptions.tools, promptOptions: inspectorOptions.promptOptions, + sourcePreview: inspectorOptions.sourcePreview, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ctx, inspectorOptions.position, inspectorOptions.highlight, promptOptionsKey], + [ctx, inspectorOptions.position, inspectorOptions.highlight, inspectorOptions.tools, promptOptionsKey, sourcePreviewKey], ); useEffect(() => { diff --git a/packages/react/src/__tests__/AskableInspector.test.tsx b/packages/react/src/__tests__/AskableInspector.test.tsx index e2994ab..4774bd0 100644 --- a/packages/react/src/__tests__/AskableInspector.test.tsx +++ b/packages/react/src/__tests__/AskableInspector.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor, act } from '@testing-library/react'; import { useState } from 'react'; import { AskableInspector } from '../AskableInspector'; import { useAskable } from '../useAskable'; -import { createAskableContext } from '@askable-ui/core'; +import { createAskableContext, createAskableSource } from '@askable-ui/core'; function ClickOnlyConsumer() { const { focus } = useAskable({ events: ['click'] }); @@ -106,4 +106,21 @@ describe('AskableInspector', () => { view.unmount(); ctx.destroy(); }); + + it('forwards tools and sourcePreview options to the core inspector', async () => { + const ctx = createAskableContext(); + ctx.registerSource('accounts', createAskableSource({ + data: { company: 'Acme Corp' }, + })); + + const view = render(); + + expect(document.querySelector('[data-askable-inspector-tool="region"]')).toBeNull(); + await waitFor(() => { + expect(document.getElementById('askable-inspector')?.textContent).toContain('Acme Corp'); + }); + + view.unmount(); + ctx.destroy(); + }); }); diff --git a/site/docs/api/core.md b/site/docs/api/core.md index 282353a..540e9c4 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -818,6 +818,14 @@ const inspector = createAskableInspector(ctx); // Shows a draggable floating panel in the bottom-right corner with test tools. // Use Copy to copy the current prompt context exactly as the panel renders it. +createAskableInspector(ctx, { + sourcePreview: { + sources: 'all', + sourceMode: 'summary', + }, +}); +// Includes resolved app-owned sources in the Prompt context preview and Copy output. + // Tear down when done: inspector.destroy(); ``` @@ -830,6 +838,7 @@ inspector.destroy(); | `highlight` | `boolean` | `true` | Outline the focused element | | `tools` | `boolean` | `true` | Show buttons for region, circle, lasso, text selection, and clear | | `promptOptions` | `AskablePromptContextOptions` | — | Options passed to `toPromptContext()` for the preview | +| `sourcePreview` | `boolean \| AskableInspectorSourcePreviewOptions` | `false` | Include resolved app-owned sources in the preview and Copy output | **Returns:** `AskableInspectorHandle` — object with `destroy()` method. diff --git a/site/docs/api/react.md b/site/docs/api/react.md index 4406c31..68d1683 100644 --- a/site/docs/api/react.md +++ b/site/docs/api/react.md @@ -89,7 +89,7 @@ import { AskableInspector } from '@askable-ui/react'; ``` **Props:** -- all core `AskableInspectorOptions` props such as `position`, `highlight`, `tools`, and `promptOptions` +- all core `AskableInspectorOptions` props such as `position`, `highlight`, `tools`, `promptOptions`, and `sourcePreview` - `ctx?: AskableContext` — reuse an explicit context - `name?: string` — match a named shared React context - `events?: AskableEvent[]` — match a shared React event configuration @@ -112,6 +112,16 @@ function DashboardDevTools() { } ``` +Set `sourcePreview` to include registered source data in the inspector preview +and Copy output: + +```tsx + +``` + --- ## `useAskable(options?)` diff --git a/site/docs/guide/inspector.md b/site/docs/guide/inspector.md index 18103a5..40a593b 100644 --- a/site/docs/guide/inspector.md +++ b/site/docs/guide/inspector.md @@ -58,6 +58,7 @@ createAskableInspector(ctx, { position: 'bottom-right', // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' highlight: true, // outline the focused element tools: true, // buttons for region, circle, lasso, text, and clear + sourcePreview: true, // include registered source data in preview/copy promptOptions: { // forwarded to toPromptContext() preset: 'compact', }, @@ -70,6 +71,19 @@ createAskableInspector(ctx, { | `highlight` | `boolean` | `true` | Draw an outline on the focused element | | `tools` | `boolean` | `true` | Show buttons for region, circle, lasso, text selection, and clear | | `promptOptions` | `AskablePromptContextOptions` | — | Options for the prompt output preview | +| `sourcePreview` | `boolean \| AskableInspectorSourcePreviewOptions` | `false` | Resolve app-owned sources into the preview and Copy output | + +Use `sourcePreview` when you want the inspector to show the same source-backed +context a chat bridge receives from `toPromptContextAsync()`. + +```ts +createAskableInspector(ctx, { + sourcePreview: { + sources: [{ id: 'accounts', mode: 'all', maxItems: 20 }], + sourceErrorMode: 'include', + }, +}); +``` ## React