diff --git a/packages/core/README.md b/packages/core/README.md index 72f2522..fd8c5b8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -252,7 +252,7 @@ tables, virtualized lists, documents, maps, charts, calendars, canvases, file trees, or custom product state. ```ts -import { createAskableCollectionSource } from '@askable-ui/core'; +import { createAskableCollectionSource, createAskableSource } from '@askable-ui/core'; const accountsSource = ctx.registerSource('accounts', createAskableCollectionSource({ describe: 'Customer accounts matching the active filters', @@ -269,6 +269,18 @@ const accountsSource = ctx.registerSource('accounts', createAskableCollectionSou }), })); +ctx.registerSource('active-chart', createAskableSource({ + kind: 'chart', + describe: 'Revenue chart', + state: () => ({ range, segment }), + modes: { + summary: () => chart.summary(), + selected: ({ selection }) => chart.pointsForSelection(selection), + all: ({ maxItems }) => chart.series().slice(0, maxItems), + }, + data: ({ mode }) => chart.export({ mode }), +})); + const prompt = await ctx.toPromptContextAsync({ sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], sourceErrorMode: 'include', @@ -281,11 +293,13 @@ table.onStateChange(() => { Source resolvers let Askable capture what the user meant while your app supplies what it knows. Use `createAskableSource()` for arbitrary documents, charts, -maps, canvases, and product state. Use `createAskableCollectionSource()` when a -list, grid, table, board, or search result has more data than the DOM currently -renders. Failed or timed-out sources are represented with a safe unavailable -marker by default; use `sourceErrorMode: 'omit'` or `'throw'` for stricter -runtimes. +maps, canvases, and product state. Its `modes` map is a concise way to expose +named slices such as `summary`, `selected`, `all`, or app-defined modes, while +`data` remains the fallback for modes you do not list. Use +`createAskableCollectionSource()` when a list, grid, table, board, or search +result has more data than the DOM currently renders. Failed or timed-out +sources are represented with a safe unavailable marker by default; use +`sourceErrorMode: 'omit'` or `'throw'` for stricter runtimes. Call `handle.notifyChanged()` or `ctx.notifySourceChanged('accounts')` when filters, pagination, query data, or selected records change without a DOM focus diff --git a/packages/core/src/__tests__/sources.test.ts b/packages/core/src/__tests__/sources.test.ts index 78220d9..7c0f372 100644 --- a/packages/core/src/__tests__/sources.test.ts +++ b/packages/core/src/__tests__/sources.test.ts @@ -29,6 +29,66 @@ describe('source helpers', () => { ctx.destroy(); }); + it('creates a generic source with named mode resolvers', async () => { + const ctx = createAskableContext(); + ctx.push({ widget: 'forecast', region: 'West' }, 'West forecast'); + ctx.registerSource('forecast', createAskableSource({ + kind: 'forecast', + state: { currency: 'USD', range: 'Q2' }, + data: ({ mode }) => ({ mode, fallback: true }), + modes: { + summary: { totalPipeline: 420000, risk: 'medium' }, + selected: ({ focus, selection }) => ({ + focus: focus?.meta, + selection, + }), + all: ({ maxItems }) => ({ + rows: ['acme', 'beta', 'cobalt'].slice(0, maxItems), + }), + }, + })); + + await expect(ctx.resolveSource('forecast', { mode: 'summary' })).resolves.toMatchObject({ + data: { totalPipeline: 420000, risk: 'medium' }, + }); + + await expect(ctx.resolveSource('forecast', { + mode: 'selected', + selection: { ids: ['deal-1'] }, + })).resolves.toMatchObject({ + data: { + focus: { widget: 'forecast', region: 'West' }, + selection: { ids: ['deal-1'] }, + }, + }); + + await expect(ctx.resolveSource('forecast', { mode: 'all', maxItems: 2 })).resolves.toMatchObject({ + data: { rows: ['acme', 'beta'] }, + }); + + await expect(ctx.resolveSource('forecast', { mode: 'custom' })).resolves.toMatchObject({ + data: { mode: 'custom', fallback: true }, + }); + + ctx.destroy(); + }); + + it('lets custom generic source resolvers override named modes', async () => { + const ctx = createAskableContext(); + ctx.registerSource('forecast', createAskableSource({ + modes: { + summary: { ignored: true }, + }, + resolve: ({ mode }) => ({ mode, custom: true }), + })); + + await expect(ctx.resolveSource('forecast', { mode: 'summary' })).resolves.toMatchObject({ + data: { mode: 'summary', custom: true }, + }); + + ctx.destroy(); + }); + it('creates a collection source that can resolve all logical items beyond the visible page', async () => { const accounts = [ { id: 'a1', company: 'Acme Corp', mrr: 8400, secret: 'token-a' }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 78cad07..83d9e7b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,8 @@ export type { AskableCollectionSourceData, AskableCreateCollectionSourceOptions, AskableCreateSourceOptions, + AskableSourceModeMap, + AskableSourceResolver, AskableSourceValue, } from './sources.js'; export type { diff --git a/packages/core/src/sources.ts b/packages/core/src/sources.ts index ea95b24..7a9c3b5 100644 --- a/packages/core/src/sources.ts +++ b/packages/core/src/sources.ts @@ -6,6 +6,10 @@ import type { } from './types.js'; export type AskableSourceValue = T | (() => T | Promise); +export type AskableSourceResolver = ( + request: AskableContextSourceResolveRequest +) => T | Promise; +export type AskableSourceModeMap = Record>; export interface AskableCreateSourceOptions { /** Source category. Examples: "document", "collection", "chart", "map", "canvas". */ @@ -16,6 +20,8 @@ export interface AskableCreateSourceOptions { state?: AskableSourceValue; /** App-owned context data. Receives the same request that custom sources receive. */ data?: TData | ((request: AskableContextSourceResolveRequest) => TData | Promise); + /** Named context slices keyed by source mode, such as "summary", "selected", "all", or app-defined modes. */ + modes?: AskableSourceModeMap; /** Custom resolver for advanced source behavior. Overrides `data` when provided. */ resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise; /** Redact or transform this source before serialization. */ @@ -77,17 +83,15 @@ export interface AskableCreateCollectionSourceOptions( options: AskableCreateSourceOptions, ): AskableContextSource { + const resolve = options.resolve ?? buildSourceResolver(options); + return { kind: options.kind, describe: options.describe, getState: options.state === undefined ? undefined : () => resolveSourceValue(options.state), - resolve: options.resolve ?? (options.data === undefined - ? undefined - : (request) => (typeof options.data === 'function' - ? (options.data as (request: AskableContextSourceResolveRequest) => TData | Promise)(request) - : options.data)), + resolve, sanitize: options.sanitize, }; } @@ -137,6 +141,30 @@ async function resolveSourceValue(value: AskableSourceValue): Promise { : value; } +function buildSourceResolver( + options: AskableCreateSourceOptions, +): AskableContextSource['resolve'] { + if (!options.modes && options.data === undefined) return undefined; + + return (request) => { + const modeValue = options.modes?.[request.mode]; + if (modeValue !== undefined) { + return resolveSourceData(modeValue, request); + } + if (options.data === undefined) return undefined; + return resolveSourceData(options.data, request); + }; +} + +function resolveSourceData( + value: T | AskableSourceResolver, + request: AskableContextSourceResolveRequest, +): T | Promise { + return typeof value === 'function' + ? (value as AskableSourceResolver)(request) + : value; +} + async function resolveCustomCollectionMode( options: AskableCreateCollectionSourceOptions, request: AskableContextSourceResolveRequest, diff --git a/site/docs/api/core.md b/site/docs/api/core.md index 569b82e..282353a 100644 --- a/site/docs/api/core.md +++ b/site/docs/api/core.md @@ -249,7 +249,12 @@ ctx.registerSource('active-document', createAskableSource({ kind: 'document', describe: 'Open editor document', state: () => ({ title: editor.title, dirty: editor.dirty }), - data: ({ mode }) => mode === 'summary' ? editor.summary() : editor.export(), + modes: { + summary: () => editor.summary(), + selected: ({ selection }) => editor.sliceForSelection(selection), + all: ({ maxTokens }) => editor.export({ maxTokens }), + }, + data: ({ mode }) => editor.export({ mode }), })); await ctx.toPromptContextAsync({ @@ -272,10 +277,15 @@ user meant; the source resolver supplies what the app knows. | `kind` | `string` | Optional category, such as `collection`, `document`, `chart`, `map`, or `custom` | | `describe` | `string \| () => string \| Promise` | Human-readable source description | | `getState` | `() => unknown \| Promise` | Current state, such as filters, sort, page, route, or viewport | +| `modes` | `Record` | Named slices for `summary`, `selected`, `all`, or app-defined source modes | +| `data` | `unknown \| (request) => unknown \| Promise` | Fallback data when the requested mode is not listed in `modes` | | `resolve` | `(request) => unknown \| Promise` | Returns selected, visible, summary, all-matching, or custom context | | `sanitize` | `(source) => source \| Promise` | Redacts or transforms resolved source context before serialization | `createAskableSource()` is a small factory for arbitrary app context. +Use its `modes` map when the source can expose named slices without a custom +switch statement; `resolve` remains available for advanced behavior and +overrides both `modes` and `data`. `createAskableCollectionSource()` adds `getItems`, `getVisibleItems`, `getSelectedItems`, `getSummary`, `maxItems`, and `sanitizeItem` so paginated or virtualized collections can expose more than the rows currently mounted in the diff --git a/site/docs/guide/serialization.md b/site/docs/guide/serialization.md index e7c0053..87bd0db 100644 --- a/site/docs/guide/serialization.md +++ b/site/docs/guide/serialization.md @@ -119,7 +119,7 @@ lists, documents, maps, charts, calendars, canvases, or API-backed dashboards, register a source that resolves data from your application state. ```ts -import { createAskableCollectionSource } from '@askable-ui/core'; +import { createAskableCollectionSource, createAskableSource } from '@askable-ui/core'; ctx.registerSource('accounts', createAskableCollectionSource({ describe: 'Customer accounts matching the active filters', @@ -135,6 +135,17 @@ ctx.registerSource('accounts', createAskableCollectionSource({ }), })); +ctx.registerSource('active-chart', createAskableSource({ + kind: 'chart', + describe: 'Revenue chart', + state: () => ({ range, segment }), + modes: { + summary: () => chart.summary(), + selected: ({ selection }) => chart.pointsForSelection(selection), + all: ({ maxItems }) => chart.series().slice(0, maxItems), + }, +})); + const context = await ctx.toPromptContextAsync({ sources: [{ id: 'accounts', mode: 'all', maxItems: 20, timeoutMs: 750 }], sourceErrorMode: 'include', @@ -145,11 +156,14 @@ This keeps Askable generic: interaction tools capture what the user meant, and source resolvers supply what the app knows. `createAskableCollectionSource()` is useful when the app owns more items than the DOM renders; `mode: 'all'` asks that source for the logical collection, while `maxItems` keeps prompt -budgets explicit. Source output can be redacted with `sanitizeItem`, a -source-level `sanitize` hook, or a context-level `sanitizeSource` hook. Failed -or timed-out sources are represented with a safe unavailable marker by default; -use `sourceErrorMode: 'omit'` or `'throw'` when your runtime needs stricter -behavior. +budgets explicit. `createAskableSource()` covers non-collection state like +documents, charts, maps, canvases, or workflows; use `modes` to expose named +slices such as `summary`, `selected`, `all`, or app-defined modes without +writing a custom resolver switch. Source output can be redacted with +`sanitizeItem`, a source-level `sanitize` hook, or a context-level +`sanitizeSource` hook. Failed or timed-out sources are represented with a safe +unavailable marker by default; use `sourceErrorMode: 'omit'` or `'throw'` when +your runtime needs stricter behavior. ## Custom labels