Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3049237
fix(ai-components): wait before loading older messages again when the…
isekovanic Nov 11, 2026
7c74ef1
fix(ai-components): keep turns steady and show a question once
isekovanic Nov 11, 2026
b05eda9
fix(ai-components): type replies out as fast as the agent writes them
isekovanic Nov 11, 2026
ab25cdb
fix(ai-components): leave client tool calls without their own id alone
isekovanic Nov 11, 2026
6a211ef
fix(ai-components): describe the app's tools to the backend as MCP tools
isekovanic Nov 11, 2026
1289dab
fix(ai-components): tell a custom message whether its reply shows UI …
isekovanic Nov 11, 2026
b97f084
fix(ai-components): show what a tool call sends before the user allow…
isekovanic Nov 11, 2026
6c9724a
fix(ai-components): offer copy and share only under replies with text
isekovanic Nov 11, 2026
a750281
fix(ai-components): offer Connect again when a sign-in never settles
isekovanic Nov 11, 2026
dc8a534
fix(ai-components): dictate and label in the user's language
isekovanic Nov 11, 2026
db0dc5a
fix(a2ui): draw surfaces that name the web SDK's catalog id
isekovanic Nov 11, 2026
d659a39
fix(a2ui): tell the model which of the app's tools only read
isekovanic Nov 11, 2026
26287f6
fix(a2ui): format numbers and dates as the device does
isekovanic Nov 11, 2026
e4dbef6
fix: render code in a monospace font on Android
isekovanic Nov 11, 2026
54ae152
fix(ai-components): measure Android code blocks in the font they're d…
isekovanic Nov 11, 2026
0a44b32
fix(ai-components): find a pending question's copy without going over…
isekovanic Nov 11, 2026
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
4 changes: 3 additions & 1 deletion examples/ExpoChatGPTSample/screens/ChatContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
ComposerView,
isAIConversationAnswering,
useAIConversationState,
useAITranslation,
} from 'stream-chat-expo/ai-components';
import { AIConversation } from 'stream-chat-expo/ai-components/conversation';

Expand Down Expand Up @@ -51,6 +52,7 @@ const ComposerArea = ({
failure: string | undefined;
}) => {
const { error } = useAIConversationState(conversation, errorSelector);
const { t } = useAITranslation();

const safeAreaInsets = useSafeAreaInsets();
const insets = useMemo(
Expand All @@ -73,7 +75,7 @@ const ComposerArea = ({
{failure ? <Text style={styles.failureText}>{failure}</Text> : null}
{error ? (
<Pressable accessibilityRole='button' onPress={() => conversation.retry()}>
<Text style={styles.failureText}>{error.message}</Text>
<Text style={styles.failureText}>{t(error.message)}</Text>
<Text style={styles.retryText}>Try again</Text>
</Pressable>
) : null}
Expand Down
4 changes: 3 additions & 1 deletion examples/ExpoCrewAssistant/screens/ChatContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
ComposerView,
isAIConversationAnswering,
useAIConversationState,
useAITranslation,
} from 'stream-chat-expo/ai-components';
import {
AIConversation,
Expand Down Expand Up @@ -79,6 +80,7 @@ const ComposerArea = ({
failure: string | undefined;
}) => {
const { error } = useAIConversationState(conversation, errorSelector);
const { t } = useAITranslation();

const safeAreaInsets = useSafeAreaInsets();
const insets = useMemo(
Expand All @@ -102,7 +104,7 @@ const ComposerArea = ({
{failure ? <Text style={styles.failureText}>{failure}</Text> : null}
{error ? (
<Pressable accessibilityRole='button' onPress={() => conversation.retry()}>
<Text style={styles.failureText}>{error.message}</Text>
<Text style={styles.failureText}>{t(error.message)}</Text>
<Text style={styles.retryText}>Try again</Text>
</Pressable>
) : null}
Expand Down
14 changes: 9 additions & 5 deletions package/shared-native/android/PerfTextViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
): Long {
val view = PerfTextView(context)

// Apply props that influence size (text, fontSize, lineHeight)
// Apply props that influence size (text, fontSize, fontFamily, lineHeight)
props?.let { p ->
if (p.hasKey("fontSize") && !p.isNull("fontSize")) {
val fs = p.getDouble("fontSize")
Expand All @@ -92,9 +92,13 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
}
}

if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) {
view.setFontFamilyCompat(p.getString("fontFamily"))
}
view.setFontFamilyCompat(
if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) {
p.getString("fontFamily")?.takeIf { it.isNotEmpty() }
} else {
null
},
)

if (p.hasKey("lineHeight") && !p.isNull("lineHeight")) {
val lh = p.getDouble("lineHeight")
Expand Down Expand Up @@ -137,7 +141,7 @@ class PerfTextViewManager : SimpleViewManager<PerfTextView>() {
val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()))
val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat()))

return YogaMeasureOutput.make(measuredWidthDp * 1.6f, measuredHeightDp * 1.0f)
return YogaMeasureOutput.make(measuredWidthDp, measuredHeightDp)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Size PerfTextMeasurementManager::measure(
serializedProps["text"] = props.text;
serializedProps["fontSize"] = props.fontSize;
serializedProps["lineHeight"] = props.lineHeight;
serializedProps["fontFamily"] = props.fontFamily;

auto propsRNM = ReadableNativeMap::newObjectCxxArgs(serializedProps);
local_ref<ReadableMap::javaobject> propsRM =
Expand Down
6 changes: 3 additions & 3 deletions package/src/a2ui/A2UIRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
supportsA2UIVersion,
} from './A2UIStream';
import { A2UISurfaceView } from './A2UISurface';
import { A2UI_DEFAULT_CATALOG_ID, a2uiTypeName } from './catalogDocument';
import { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID, a2uiTypeName } from './catalogDocument';
import { a2uiCatalogsOf } from './catalogs';
import { A2UI_BASIC_COMPONENTS } from './components/basicComponents';
import type { A2UIComponents, A2UIIcons } from './components/types';
Expand All @@ -44,7 +44,7 @@ import {
import type { AIUIEngine, AIUIRenderer } from '../ai-components/generative-ui/AIUIRenderer';
import { primitives } from '../ai-components/internal/tokens';

export { A2UI_DEFAULT_CATALOG_ID };
export { A2UI_DEFAULT_CATALOG_ID, A2UI_WEB_CATALOG_ID };

/** What went wrong in a reply's A2UI, as the A2UI specification reports it to an agent. */
export type A2UIError = {
Expand Down Expand Up @@ -78,7 +78,7 @@ export type A2UIRendererOptions = {
* answers to write as UI.
*/
instructions?: string;
/** The locale numbers and dates are formatted in. `en-US` by default. */
/** The locale numbers and dates are formatted in. The device's by default. */
locale?: string;
/**
* Decides what an Image, Video or AudioPlayer loads for the link the agent wrote: the address
Expand Down
14 changes: 14 additions & 0 deletions package/src/a2ui/__tests__/A2UIInstructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { parseAIUIComponentDefinition } from '../../ai-components';
import { ohridLines, uiComponents } from '../../mock-builders/a2ui/ohrid';
import {
A2UI_DEFAULT_CATALOG_ID,
A2UI_WEB_CATALOG_ID,
a2uiInstructions,
A2UIInstructionsError,
a2uiProblems,
Expand Down Expand Up @@ -217,6 +218,19 @@ describe('a2uiProblems', () => {
].join('\n'),
),
).toEqual([expect.stringMatching(/^surface s is created again/)]);
expect(
await a2uiProblems(
[
line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }),
line({
updateComponents: {
components: [{ component: 'Text', id: 'root', text: 'One' }],
surfaceId: 's',
},
}),
].join('\n'),
),
).toEqual([]);
expect(
await a2uiProblems(
[
Expand Down
47 changes: 46 additions & 1 deletion package/src/a2ui/__tests__/A2UIRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import basicCatalogJSON from '../basicCatalog.json';
import { a2uiAppCatalogDocument, a2uiTypeName } from '../catalogDocument';
import { formatMoment, parseMoment } from '../components/DateTimeInput';
import { A2UI_ICON_PATHS } from '../components/iconPaths';
import { A2UI_DEFAULT_CATALOG_ID, type A2UIRendererOptions, createA2UIRenderer } from '../index';
import {
A2UI_DEFAULT_CATALOG_ID,
A2UI_WEB_CATALOG_ID,
type A2UIRendererOptions,
createA2UIRenderer,
} from '../index';
import { withoutPaths, withoutSecrets } from '../secrets';

const definitionOf = (name: string) => {
Expand Down Expand Up @@ -322,6 +327,20 @@ describe('the A2UI versions and catalog it reads', () => {
});

describe('a reply written in A2UI', () => {
it('draws a surface that names the web SDK’s catalog id', async () => {
const { errors, ui } = setup();
await renderReply(ui, [
line({ createSurface: { catalogId: A2UI_WEB_CATALOG_ID, surfaceId: 's' } }),
line({
updateComponents: {
components: [{ component: 'Text', id: 'root', text: 'From the web agent' }],
surfaceId: 's',
},
}),
]);
await waitFor(() => expect(screen.getByText(/From the web agent/)).toBeTruthy());
expect(errors).toEqual([]);
});
it('shows the Ohrid plan with the basic catalog and the app’s components', async () => {
const { errors, ui } = setup();
await renderReply(ui, ohridLines);
Expand Down Expand Up @@ -880,6 +899,9 @@ describe('the app’s tools in a reply’s UI', () => {
expect(instructions).toContain('"weather"');
expect(instructions).toContain('The weather in a city today.');
expect(instructions).toContain('"city"');
expect(instructions).toContain("## The app's tools");
expect(instructions).toContain('- `weather` (read-only): The weather in a city today.');
expect(instructions).toContain('Any other runs only from a Button');
});

it('shows the rest of the UI when a tool fails, and says so', async () => {
Expand Down Expand Up @@ -1296,6 +1318,29 @@ describe('the basic catalog', () => {
expect(screen.getByText(/Saturday 14 November/)).toBeTruthy();
});

it('formats yen without decimals, and epoch timestamps and times of day', async () => {
const { ui } = setup({ locale: 'en-US' });
const text = (id: string, call: string, args: Record<string, unknown>) => ({
component: 'Text',
id,
text: { '@call': call, args },
});
await renderReply(
ui,
surfaceOf([
{ children: ['yen', 'seconds', 'millis', 'time'], component: 'Column', id: 'root' },
text('yen', 'formatCurrency', { currency: 'JPY', value: 1500 }),
text('seconds', 'formatDate', { format: 'yyyy-MM-dd', value: 1_763_078_400 }),
text('millis', 'formatDate', { format: 'd MMMM', value: 1_763_078_400_000 }),
text('time', 'formatDate', { format: 'h:mm a', value: '18:30' }),
]),
);
await waitFor(() => expect(screen.getByText('¥1,500')).toBeTruthy());
expect(screen.getByText('2025-11-14')).toBeTruthy();
expect(screen.getByText('14 November')).toBeTruthy();
expect(screen.getByText('6:30 PM')).toBeTruthy();
});

it('keeps what the person types in the data model, and holds a button until its checks pass', async () => {
const { sent, ui } = setup();
await renderReply(
Expand Down
51 changes: 41 additions & 10 deletions package/src/a2ui/basicFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,20 @@ import {
isSignal,
} from '@a2ui/web_core';

/** The locale formatting falls back to, so it never depends on the host's. */
/** The locale formatting falls back to when neither the app nor the device names one it has. */
const DEFAULT_LOCALE = 'en-US';

const resolveLocale = (locale?: string) => {
if (!locale) return DEFAULT_LOCALE;
// The device's locale, as the person reads numbers and dates elsewhere.
const deviceLocale = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().locale || DEFAULT_LOCALE;
} catch {
return DEFAULT_LOCALE;
}
};

const resolveLocale = (asked?: string) => {
const locale = asked || deviceLocale();
try {
return Intl.NumberFormat.supportedLocalesOf(locale).length > 0 ? locale : DEFAULT_LOCALE;
} catch {
Expand Down Expand Up @@ -146,18 +155,21 @@ const formatNumber = (
useGrouping: grouping,
}).format(value);

// A currency's amount with `decimals` decimals, or as many as the currency has, such as none for
// yen.
const formatCurrency = (
value: number,
code: unknown,
decimals = 2,
decimals?: number,
grouping?: boolean,
locale = DEFAULT_LOCALE,
) => {
if (isNaN(value)) return '';
const currency = String(code).toUpperCase();
const options = {
maximumFractionDigits: decimals,
minimumFractionDigits: decimals,
...(decimals === undefined
? {}
: { maximumFractionDigits: decimals, minimumFractionDigits: decimals }),
useGrouping: grouping,
};
try {
Expand Down Expand Up @@ -242,9 +254,28 @@ const parseTimestamp = (value: string) => {
return fieldsRoundTrip(value, shifted) ? { instant, shifted } : null;
};

// Epoch milliseconds start beyond this; a smaller number is epoch seconds.
const EPOCH_MILLISECONDS_FROM = 100_000_000_000;
const TIME_OF_DAY = /^\d{2}:\d{2}(:\d{2})?$/;

// An epoch timestamp, in seconds or milliseconds, as the device's clock reads it.
const parseEpoch = (value: number) => {
const instant = new Date(Math.abs(value) > EPOCH_MILLISECONDS_FROM ? value : value * 1000);
if (isNaN(instant.getTime())) return null;
return {
instant,
shifted: new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000),
};
};

const formatDate = (value: unknown, pattern = 'yyyy-MM-dd', locale = DEFAULT_LOCALE) => {
if (!value) return '';
const parsed = parseTimestamp(String(value));
if (value === undefined || value === null || value === '') return '';
const parsed =
typeof value === 'number'
? parseEpoch(value)
: TIME_OF_DAY.test(String(value))
? parseTimestamp(`1970-01-01T${String(value)}`)
: parseTimestamp(String(value));
if (!parsed) return '';
const { instant, shifted } = parsed;
const format = pattern || 'yyyy-MM-dd';
Expand Down Expand Up @@ -372,7 +403,7 @@ type Execute = (args: Args, context: DataContext) => unknown;

/**
* The basic catalog's functions, for the function definitions `apis` of its document, with
* `openUrl` run by `openUrl`. Formatting follows `locale`, or `en-US`.
* `openUrl` run by `openUrl`. Formatting follows `locale`, or the device's.
*/
export const basicFunctionsOf = (
apis: Map<string, FunctionApi>,
Expand All @@ -388,7 +419,7 @@ export const basicFunctionsOf = (
formatCurrency(
number(value),
currency,
optionalNumber(decimals) ?? 2,
optionalNumber(decimals),
grouping as boolean | undefined,
resolved,
),
Expand Down
10 changes: 10 additions & 0 deletions package/src/a2ui/catalogDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import type { AIClientToolDefinition } from '../ai-components/tool-calls/AIClien
/** The id of the catalog with the app's components, unless the renderer is given another. */
export const A2UI_DEFAULT_CATALOG_ID = 'getstream.io:app-catalog';

/**
* The id the web SDK names the same catalog with. A renderer on the default id draws surfaces
* that name it too, so an agent set up for a web app shows its UI here as well.
*/
export const A2UI_WEB_CATALOG_ID = 'stream-app-catalog';

/** The ids a surface may name for a renderer whose catalog is `catalogId`. */
export const a2uiAppCatalogIds = (catalogId: string) =>
catalogId === A2UI_DEFAULT_CATALOG_ID ? [catalogId, A2UI_WEB_CATALOG_ID] : [catalogId];

const definitionOf = (component: AIUIComponent | AIUIComponentDefinition) =>
'definition' in component ? component.definition : component;

Expand Down
10 changes: 3 additions & 7 deletions package/src/a2ui/catalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { A2UI_VERSION } from './A2UIStream';
import basicCatalogDocument from './basicCatalog.json';
import { basicFunctionsOf } from './basicFunctions';
import { a2uiAppCatalogDocument, a2uiTypeName } from './catalogDocument';
import { a2uiAppCatalogDocument, a2uiAppCatalogIds, a2uiTypeName } from './catalogDocument';
import { secretValuesOf, withoutSecrets } from './secrets';

import type { AIUIComponent } from '../ai-components/generative-ui/AIUIComponent';
Expand Down Expand Up @@ -176,10 +176,6 @@ export const a2uiCatalogsOf = ({
}
const basic = [...definitions.components.values()].filter(({ name }) => !removed.has(name));
const all = [...basic, ...appApis] as ComponentApi[];
return [
new Catalog<ComponentApi>(catalogId, A2UI_VERSION, all, functions),
...(catalogId === A2UI_BASIC_CATALOG_ID
? []
: [new Catalog<ComponentApi>(A2UI_BASIC_CATALOG_ID, A2UI_VERSION, all, functions)]),
];
const ids = new Set([...a2uiAppCatalogIds(catalogId), A2UI_BASIC_CATALOG_ID]);
return [...ids].map((id) => new Catalog<ComponentApi>(id, A2UI_VERSION, all, functions));
};
1 change: 1 addition & 0 deletions package/src/a2ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// agent never writes A2UI doesn't bundle the A2UI core.
export {
A2UI_DEFAULT_CATALOG_ID,
A2UI_WEB_CATALOG_ID,
type A2UIError,
type A2UIRendererOptions,
createA2UIRenderer,
Expand Down
Loading
Loading