Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion packages/core/src/__tests__/inspector.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type {
AskableInspectorHandle,
AskableInspectorOptions,
AskableInspectorPosition,
AskableInspectorSourcePreviewOptions,
} from './inspector.js';
export type {
AskableRegionCaptureHandle,
Expand Down
89 changes: 81 additions & 8 deletions packages/core/src/inspector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type {
AskableAsyncPromptContextOptions,
AskableContext,
AskableContextSourceInfo,
AskableFocus,
AskableContextSourceInclude,
AskableContextSourceErrorMode,
AskableContextSourceMode,
AskablePromptContextOptions,
} from './types.js';
import { createAskableRegionCapture } from './capture.js';
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -60,8 +81,22 @@ function renderMeta(meta: Record<string, unknown> | string): string {
return `{\n${lines}\n}`;
}

function buildPromptContextHTML(promptContext: string): string {
return `
<div>
<span style="color:#7ee787;font-size:10px;text-transform:uppercase;letter-spacing:.05em">Prompt context</span><br>
<pre style="color:#a5d6ff;margin:4px 0;white-space:pre-wrap;word-break:break-all">${escapeHtml(promptContext)}</pre>
</div>
`;
}

function buildFocusHTML(focus: AskableFocus | null, promptContext: string): string {
if (!focus) return '<div style="color:#8b949e;font-style:italic;padding:4px 0">No element focused</div>';
if (!focus) {
return `
<div style="color:#8b949e;font-style:italic;padding:4px 0">No element focused</div>
${buildPromptContextHTML(promptContext)}
`;
}
const el = focus.element;
const tag = el ? el.tagName.toLowerCase() : null;
const id = el?.id ? `#${escapeHtml(el.id)}` : '';
Expand All @@ -87,10 +122,7 @@ function buildFocusHTML(focus: AskableFocus | null, promptContext: string): stri
<code style="color:#e6edf3">"${escapeHtml(focus.text.slice(0, 120))}${focus.text.length > 120 ? '…' : ''}"</code>
</div>
` : ''}
<div>
<span style="color:#7ee787;font-size:10px;text-transform:uppercase;letter-spacing:.05em">Prompt context</span><br>
<pre style="color:#a5d6ff;margin:4px 0;white-space:pre-wrap;word-break:break-all">${escapeHtml(promptContext)}</pre>
</div>
${buildPromptContextHTML(promptContext)}
`;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -225,6 +275,7 @@ export function createAskableInspector(
const {
position = 'bottom-right',
promptOptions,
sourcePreview = false,
highlight = true,
tools = true,
} = options;
Expand Down Expand Up @@ -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) {
Expand All @@ -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());

Expand All @@ -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: {
Expand Down
4 changes: 3 additions & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
? <AskableInspector events={['click']} position="bottom-left" />
? <AskableInspector events={['click']} position="bottom-left" sourcePreview />
: null;
}
```
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/AskableInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
19 changes: 18 additions & 1 deletion packages/react/src/__tests__/AskableInspector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
Expand Down Expand Up @@ -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(<AskableInspector ctx={ctx} tools={false} sourcePreview />);

expect(document.querySelector('[data-askable-inspector-tool="region"]')).toBeNull();
await waitFor(() => {
expect(document.getElementById('askable-inspector')?.textContent).toContain('Acme Corp');
});

view.unmount();
ctx.destroy();
});
});
9 changes: 9 additions & 0 deletions site/docs/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
```
Expand All @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion site/docs/api/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -112,6 +112,16 @@ function DashboardDevTools() {
}
```

Set `sourcePreview` to include registered source data in the inspector preview
and Copy output:

```tsx
<AskableInspector
ctx={ctx}
sourcePreview={{ sources: [{ id: 'accounts', mode: 'summary' }] }}
/>
```

---

## `useAskable(options?)`
Expand Down
Loading
Loading