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
5 changes: 5 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ const accountsSource = ctx.registerSource('accounts', createAskableCollectionSou
getState: () => ({ filters, sort, page, pageSize, totalCount }),
getVisibleItems: () => table.getVisibleRows(),
getSelectedItems: ({ selection }) => getAccountsByIds(selection),
getItemId: (account) => account.id,
getItems: () => accountStore.getAllMatching({ filters, sort }),
getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }),
maxItems: 50,
Expand Down Expand Up @@ -371,6 +372,10 @@ named slices such as `summary`, `selected`, `all`, or app-defined modes, while
result has more data than the DOM currently renders. Use
`createAskablePageSource()` when an extension, fallback bridge, or unannotated
page still needs selected text, headings, optional links, and full-page text.
For packet-driven selections, `getItemId` lets Askable map selected ids or
selected item labels from region, circle, square, lasso, or text packets back to
full app-owned collection items. Use `getSelectionItemId` when your selected
item metadata stores the id in an app-specific shape.
Failed or timed-out sources are represented with a safe unavailable marker by
default; use `sourceErrorMode: 'omit'` or `'throw'` for stricter runtimes.

Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/__tests__/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,119 @@ describe('source helpers', () => {
ctx.destroy();
});

it('resolves packet-selected ids to full collection items', async () => {
const accounts = [
{ id: 'a1', company: 'Everlane Ops', mrr: 8400, secret: 'token-a' },
{ id: 'b2', company: 'Mercury Labs', mrr: 5200, secret: 'token-b' },
{ id: 'c3', company: 'Northstar Clinic', mrr: 2100, secret: 'token-c' },
];
const ctx = createAskableContext();
ctx.registerSource('accounts', createAskableCollectionSource({
getItems: () => accounts,
getItemId: (account) => account.id,
sanitizeItem: ({ secret: _secret, ...safe }) => safe,
}));

expect(ctx.listSources()[0].modes).toEqual(['selected', 'all']);

const selected = await ctx.resolveSource('accounts', {
mode: 'selected',
selection: {
capture: { mode: 'lasso', gesture: 'drag' },
source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' },
target: {
metadata: {
selectedIds: ['b2', 'c3'],
},
},
},
});

expect(selected.data).toEqual({
mode: 'selected',
items: [
{ id: 'b2', company: 'Mercury Labs', mrr: 5200 },
{ id: 'c3', company: 'Northstar Clinic', mrr: 2100 },
],
totalCount: 2,
returnedCount: 2,
truncated: false,
});
expect(JSON.stringify(selected)).not.toContain('token-b');

ctx.destroy();
});

it('resolves packet-selected item labels to full collection items', async () => {
const accounts = [
{ company: 'Everlane Ops', mrr: 8400 },
{ company: 'Mercury Labs', mrr: 5200 },
];
const ctx = createAskableContext();
ctx.registerSource('accounts', createAskableCollectionSource({
getItems: () => accounts,
getItemId: (account) => account.company,
}));

const selected = await ctx.resolveSource('accounts', {
mode: 'selected',
selection: {
capture: { mode: 'region', gesture: 'drag' },
source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' },
target: {
metadata: {
selectedItems: [{ label: 'Mercury Labs' }],
},
},
},
});

expect(selected.data).toMatchObject({
mode: 'selected',
items: [{ company: 'Mercury Labs', mrr: 5200 }],
returnedCount: 1,
});

ctx.destroy();
});

it('supports custom selected item id mapping for app-specific metadata', async () => {
const accounts = [
{ id: 'acct_1', company: 'Everlane Ops' },
{ id: 'acct_2', company: 'Mercury Labs' },
];
const ctx = createAskableContext();
ctx.registerSource('accounts', createAskableCollectionSource({
getItems: () => accounts,
getItemId: (account) => account.id,
getSelectionItemId: (selectionItem) => {
if (!selectionItem || typeof selectionItem !== 'object') return undefined;
const meta = (selectionItem as { meta?: { accountId?: unknown } }).meta;
return typeof meta?.accountId === 'string' ? meta.accountId : undefined;
},
}));

const selected = await ctx.resolveSource('accounts', {
mode: 'selected',
selection: {
capture: { mode: 'lasso', gesture: 'drag' },
source: { timestamp: '2026-06-05T00:00:00.000Z', route: '/accounts' },
target: {
metadata: {
selectedItems: [{ label: 'Mercury Labs', meta: { accountId: 'acct_2' } }],
},
},
},
});

expect(selected.data).toMatchObject({
mode: 'selected',
items: [{ id: 'acct_2', company: 'Mercury Labs' }],
});

ctx.destroy();
});

it('advertises custom source modes without resolving data', () => {
const ctx = createAskableContext();
const resolve = () => ({ ok: true });
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 @@ -55,6 +55,7 @@ export type {
AskableTextSelectionCaptureTheme,
} from './selection.js';
export type {
AskableCollectionItemId,
AskableCollectionSourceData,
AskableCreateCollectionSourceOptions,
AskableCreateSourceOptions,
Expand Down
115 changes: 114 additions & 1 deletion packages/core/src/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface AskableCollectionSourceData<TItem = unknown> {
truncated?: boolean;
}

export type AskableCollectionItemId = string | number;

export interface AskableCreateCollectionSourceOptions<TItem = unknown, TState = unknown> {
/** Source category. Defaults to "collection". */
kind?: string;
Expand All @@ -63,6 +65,23 @@ export interface AskableCreateCollectionSourceOptions<TItem = unknown, TState =
getVisibleItems?: () => readonly TItem[] | Promise<readonly TItem[]>;
/** Items explicitly selected by the user or active app state. */
getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise<readonly TItem[]>;
/**
* Stable item id used to resolve packet-selected ids or labels back to full
* app-owned collection items. This lets selected DOM/region/lasso packets
* include records beyond the currently visible page.
*/
getItemId?: (
item: TItem,
request: AskableContextSourceResolveRequest,
) => AskableCollectionItemId | null | undefined;
/**
* Optional mapper for app-specific selected item metadata. By default Askable
* reads primitive ids and common object keys such as `id`, `key`, and `label`.
*/
getSelectionItemId?: (
selectionItem: unknown,
request: AskableContextSourceResolveRequest,
) => AskableCollectionItemId | null | undefined;
/** Lightweight aggregate summary for prompt budgets. */
getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise<unknown>;
/** Fallback for custom modes. */
Expand Down Expand Up @@ -181,7 +200,7 @@ function inferCollectionSourceModes<TItem, TState>(
...(options.getState ? ['state' as const] : []),
...(options.getSummary ? ['summary' as const] : []),
...(options.getVisibleItems ? ['visible' as const] : []),
...(options.getSelectedItems ? ['selected' as const] : []),
...(options.getSelectedItems || (options.getItems && options.getItemId) ? ['selected' as const] : []),
...(options.getItems ? ['all' as const] : []),
...(options.advertisedModes ?? []),
]);
Expand Down Expand Up @@ -236,6 +255,21 @@ async function resolveCustomCollectionMode<TItem, TState>(
return collectionItemsResult(await options.getSelectedItems(request), options, request);
}

if (request.mode === 'selected' && options.getItems && options.getItemId) {
const selectedIds = extractSelectedCollectionItemIds(request, options);
if (selectedIds.size > 0) {
const items = await options.getItems();
return collectionItemsResult(
items.filter((item) => {
const id = options.getItemId?.(item, request);
return id !== null && id !== undefined && selectedIds.has(normalizeCollectionItemId(id));
}),
options,
request,
);
}
}

if (request.mode === 'all' && options.getItems) {
return collectionItemsResult(await options.getItems(), options, request);
}
Expand All @@ -247,6 +281,85 @@ async function resolveCustomCollectionMode<TItem, TState>(
return undefined;
}

function extractSelectedCollectionItemIds<TItem>(
request: AskableContextSourceResolveRequest,
options: AskableCreateCollectionSourceOptions<TItem>,
): Set<string> {
const ids = new Set<string>();

function add(value: unknown): void {
const id = extractCollectionItemId(value, request, options.getSelectionItemId);
if (id === null || id === undefined) return;
ids.add(normalizeCollectionItemId(id));
}

function addMany(value: unknown): void {
if (Array.isArray(value)) {
value.forEach(add);
return;
}
add(value);
}

function readContainer(value: unknown): void {
if (!value || typeof value !== 'object') {
addMany(value);
return;
}

const record = value as Record<string, unknown>;
for (const key of ['selectedIds', 'selectedItemIds', 'ids', 'itemIds', 'selectedKeys', 'keys']) {
addMany(record[key]);
}
for (const key of ['selectedItems', 'items']) {
const selectedItems = record[key];
if (Array.isArray(selectedItems)) selectedItems.forEach(add);
}
}

readContainer(request.selection);
if (isAskablePacketSourceSelection(request.selection)) {
readContainer(request.selection.target?.metadata);
}

return ids;
}

function extractCollectionItemId(
value: unknown,
request: AskableContextSourceResolveRequest,
getSelectionItemId?: (
selectionItem: unknown,
request: AskableContextSourceResolveRequest,
) => AskableCollectionItemId | null | undefined,
): AskableCollectionItemId | null | undefined {
const custom = getSelectionItemId?.(value, request);
if (custom !== null && custom !== undefined) return custom;
if (typeof value === 'string' || typeof value === 'number') return value;
if (!value || typeof value !== 'object') return undefined;

const record = value as Record<string, unknown>;
for (const key of ['id', 'key', 'label']) {
const candidate = record[key];
if (typeof candidate === 'string' || typeof candidate === 'number') return candidate;
}

const meta = record.meta;
if (meta && typeof meta === 'object') {
const metaRecord = meta as Record<string, unknown>;
for (const key of ['id', 'key', 'label']) {
const candidate = metaRecord[key];
if (typeof candidate === 'string' || typeof candidate === 'number') return candidate;
}
}

return undefined;
}

function normalizeCollectionItemId(id: AskableCollectionItemId): string {
return String(id);
}

async function collectionItemsResult<TItem>(
items: readonly TItem[],
options: AskableCreateCollectionSourceOptions<TItem>,
Expand Down
12 changes: 9 additions & 3 deletions site/docs/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ const handle = ctx.registerSource('accounts', createAskableCollectionSource({
}),
getVisibleItems: () => table.getVisibleRows(),
getSelectedItems: ({ selection }) => getSelectedAccounts(selection),
getItemId: (account) => account.id,
getItems: () => accountStore.getAllMatching({ filters, sort }),
getSummary: ({ focus, maxItems }) => summarizeAccounts({ filters, sort, focus, maxItems }),
maxItems: 50,
Expand Down Expand Up @@ -298,9 +299,14 @@ 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
DOM without a table-specific API.
`getSelectedItems`, `getItemId`, `getSummary`, `maxItems`, and `sanitizeItem`
so paginated or virtualized collections can expose more than the rows currently
mounted in the DOM without a table-specific API.
When `getItemId` and `getItems` are present, `selected` mode can resolve packet
metadata such as `selectedIds`, `selectedItemIds`, or `selectedItems` from
region, circle, square, or lasso captures back to full collection items. Use
`getSelectionItemId` when selected item metadata stores the id under an
app-specific key.
`createAskablePageSource()` snapshots unannotated pages for extension and
fallback contexts. It supports `summary`, `selected`, and `all` modes for page
title, URL, selected text, headings, optional links, and capped full-page text.
Expand Down
2 changes: 2 additions & 0 deletions site/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ interface AskableCreateCollectionSourceOptions<TItem = unknown, TState = unknown
getItems?: () => readonly TItem[] | Promise<readonly TItem[]>;
getVisibleItems?: () => readonly TItem[] | Promise<readonly TItem[]>;
getSelectedItems?: (request: AskableContextSourceResolveRequest) => readonly TItem[] | Promise<readonly TItem[]>;
getItemId?: (item: TItem, request: AskableContextSourceResolveRequest) => string | number | null | undefined;
getSelectionItemId?: (selectionItem: unknown, request: AskableContextSourceResolveRequest) => string | number | null | undefined;
getSummary?: (request: AskableContextSourceResolveRequest) => unknown | Promise<unknown>;
resolve?: (request: AskableContextSourceResolveRequest) => unknown | Promise<unknown>;
maxItems?: number;
Expand Down
1 change: 1 addition & 0 deletions site/docs/guide/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({
describe: 'Customer accounts matching the active filters',
getState: () => ({ filters, sort, page, pageSize, totalCount }),
getVisibleItems: () => table.getRowModel().rows.map((row) => row.original),
getItemId: (account) => account.id,
getItems: () => accountStore.getAllMatching({ filters, sort }),
getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }),
sanitizeItem: redactAccountFields,
Expand Down
1 change: 1 addition & 0 deletions site/docs/guide/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({
getState: () => ({ filters, sort, page, pageSize, totalCount }),
getVisibleItems: () => table.getVisibleRows(),
getSelectedItems: ({ selection }) => getAccountsByIds(selection),
getItemId: (account) => account.id,
getItems: () => accountStore.getAllMatching({ filters, sort }),
getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }),
sanitizeItem: redactAccountFields,
Expand Down
1 change: 1 addition & 0 deletions site/docs/guide/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ ctx.registerSource('accounts', createAskableCollectionSource({
getState: () => ({ filters, sort, page, pageSize, totalCount }),
getVisibleItems: () => table.getVisibleRows(),
getSelectedItems: ({ selection }) => getAccountsByIds(selection),
getItemId: (account) => account.id,
getItems: () => accountStore.getAllMatching({ filters, sort }),
getSummary: ({ maxItems }) => summarizeAccounts({ filters, sort, maxItems }),
sanitizeItem: redactAccountFields,
Expand Down
Loading
Loading