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
26 changes: 20 additions & 6 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/__tests__/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type {
AskableCollectionSourceData,
AskableCreateCollectionSourceOptions,
AskableCreateSourceOptions,
AskableSourceModeMap,
AskableSourceResolver,
AskableSourceValue,
} from './sources.js';
export type {
Expand Down
38 changes: 33 additions & 5 deletions packages/core/src/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import type {
} from './types.js';

export type AskableSourceValue<T> = T | (() => T | Promise<T>);
export type AskableSourceResolver<T> = (
request: AskableContextSourceResolveRequest
) => T | Promise<T>;
export type AskableSourceModeMap<T> = Record<string, T | AskableSourceResolver<T>>;

export interface AskableCreateSourceOptions<TData = unknown, TState = unknown> {
/** Source category. Examples: "document", "collection", "chart", "map", "canvas". */
Expand All @@ -16,6 +20,8 @@ export interface AskableCreateSourceOptions<TData = unknown, TState = unknown> {
state?: AskableSourceValue<TState>;
/** App-owned context data. Receives the same request that custom sources receive. */
data?: TData | ((request: AskableContextSourceResolveRequest) => TData | Promise<TData>);
/** Named context slices keyed by source mode, such as "summary", "selected", "all", or app-defined modes. */
modes?: AskableSourceModeMap<TData>;
/** Custom resolver for advanced source behavior. Overrides `data` when provided. */
resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise<unknown>;
/** Redact or transform this source before serialization. */
Expand Down Expand Up @@ -77,17 +83,15 @@ export interface AskableCreateCollectionSourceOptions<TItem = unknown, TState =
export function createAskableSource<TData = unknown, TState = unknown>(
options: AskableCreateSourceOptions<TData, TState>,
): 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<TData>)(request)
: options.data)),
resolve,
sanitize: options.sanitize,
};
}
Expand Down Expand Up @@ -137,6 +141,30 @@ async function resolveSourceValue<T>(value: AskableSourceValue<T>): Promise<T> {
: value;
}

function buildSourceResolver<TData, TState>(
options: AskableCreateSourceOptions<TData, TState>,
): 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<T>(
value: T | AskableSourceResolver<T>,
request: AskableContextSourceResolveRequest,
): T | Promise<T> {
return typeof value === 'function'
? (value as AskableSourceResolver<T>)(request)
: value;
}

async function resolveCustomCollectionMode<TItem, TState>(
options: AskableCreateCollectionSourceOptions<TItem, TState>,
request: AskableContextSourceResolveRequest,
Expand Down
12 changes: 11 additions & 1 deletion site/docs/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<string>` | Human-readable source description |
| `getState` | `() => unknown \| Promise<unknown>` | Current state, such as filters, sort, page, route, or viewport |
| `modes` | `Record<string, value \| resolver>` | Named slices for `summary`, `selected`, `all`, or app-defined source modes |
| `data` | `unknown \| (request) => unknown \| Promise<unknown>` | Fallback data when the requested mode is not listed in `modes` |
| `resolve` | `(request) => unknown \| Promise<unknown>` | Returns selected, visible, summary, all-matching, or custom context |
| `sanitize` | `(source) => source \| Promise<source>` | 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
Expand Down
26 changes: 20 additions & 6 deletions site/docs/guide/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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

Expand Down
Loading