@assistant-ui/react-native 0.1.46 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +2 -1
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +2 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +2 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +55 -38
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/message/MessageContent.d.ts.map +1 -1
- package/dist/primitives/message/MessageContent.js +13 -10
- package/dist/primitives/message/MessageContent.js.map +1 -1
- package/dist/primitives/message/MessageParts.d.ts +1 -0
- package/dist/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/primitives/message/MessageParts.js +22 -3
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message.js +1 -1
- package/dist/primitives/messagePart.js +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts +10 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts.map +1 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.js +47 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.js.map +1 -0
- package/dist/primitives/threadListItem.d.ts +1 -1
- package/dist/primitives/threadListItem.d.ts.map +1 -1
- package/dist/primitives/threadListItem.js +3 -3
- package/dist/primitives/threadListItem.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +4 -0
- package/src/primitives/actionBar/ActionBar.test.tsx +45 -0
- package/src/primitives/actionBar/ActionBarCopy.tsx +12 -3
- package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +13 -3
- package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +13 -3
- package/src/primitives/composer/ComposerInput.test.tsx +64 -4
- package/src/primitives/composer/ComposerInput.tsx +13 -2
- package/src/primitives/message/MessageContent.runtime.test.tsx +35 -0
- package/src/primitives/message/MessageContent.test.tsx +68 -4
- package/src/primitives/message/MessageContent.tsx +19 -9
- package/src/primitives/message/MessageParts.test.tsx +68 -1
- package/src/primitives/message/MessageParts.tsx +17 -1
- package/src/primitives/threadListItem/ThreadListItemTitle.test.tsx +164 -0
- package/src/primitives/threadListItem/ThreadListItemTitle.tsx +23 -0
- package/src/primitives/threadListItem.ts +4 -1
|
@@ -17,7 +17,10 @@ const h = vi.hoisted(() => ({
|
|
|
17
17
|
return this.content;
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
|
-
tools: {
|
|
20
|
+
tools: {
|
|
21
|
+
toolUIs: {} as Record<string, unknown>,
|
|
22
|
+
mcpApp: undefined as { render: unknown } | undefined,
|
|
23
|
+
},
|
|
21
24
|
dataRenderers: {
|
|
22
25
|
renderers: {} as Record<string, unknown>,
|
|
23
26
|
fallbacks: [] as unknown[],
|
|
@@ -25,7 +28,8 @@ const h = vi.hoisted(() => ({
|
|
|
25
28
|
},
|
|
26
29
|
}));
|
|
27
30
|
|
|
28
|
-
vi.mock("@assistant-ui/store", () => {
|
|
31
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
32
|
+
const actual = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
29
33
|
const message = Object.assign(() => message, {
|
|
30
34
|
part: ({ index }: { index: number }) => ({
|
|
31
35
|
addToolResult: (...args: unknown[]) => h.addToolResult(index, ...args),
|
|
@@ -38,6 +42,7 @@ vi.mock("@assistant-ui/store", () => {
|
|
|
38
42
|
});
|
|
39
43
|
const aui = { message };
|
|
40
44
|
return {
|
|
45
|
+
...actual,
|
|
41
46
|
useAui: () => aui,
|
|
42
47
|
useAuiState: <T,>(selector: (s: typeof h.state) => T) => selector(h.state),
|
|
43
48
|
};
|
|
@@ -56,6 +61,7 @@ describe("MessageContent", () => {
|
|
|
56
61
|
h.unstable_recordInteraction.mockReset();
|
|
57
62
|
h.state.message.content = [];
|
|
58
63
|
h.state.tools.toolUIs = {};
|
|
64
|
+
h.state.tools.mcpApp = undefined;
|
|
59
65
|
h.state.dataRenderers.renderers = {};
|
|
60
66
|
h.state.dataRenderers.fallbacks = [];
|
|
61
67
|
|
|
@@ -153,9 +159,8 @@ describe("MessageContent", () => {
|
|
|
153
159
|
expect(container.textContent).toBe("");
|
|
154
160
|
});
|
|
155
161
|
|
|
156
|
-
it("renders null for
|
|
162
|
+
it("renders null for reasoning, source, and file without renderers", async () => {
|
|
157
163
|
h.state.message.content = [
|
|
158
|
-
{ type: "image", image: "x" },
|
|
159
164
|
{ type: "reasoning", text: "r" },
|
|
160
165
|
{ type: "source", sourceType: "url", id: "1", url: "u" },
|
|
161
166
|
{ type: "file", filename: "f" },
|
|
@@ -186,6 +191,7 @@ describe("MessageContent", () => {
|
|
|
186
191
|
await mount({ renderImage, renderReasoning, renderSource, renderFile });
|
|
187
192
|
|
|
188
193
|
expect(container.textContent).toBe("image-0reasoning-1source-2file-3");
|
|
194
|
+
expect(container.querySelector("img")).toBeNull();
|
|
189
195
|
expect(renderImage).toHaveBeenCalledWith({
|
|
190
196
|
part: h.state.message.content[0],
|
|
191
197
|
index: 0,
|
|
@@ -262,6 +268,64 @@ describe("MessageContent", () => {
|
|
|
262
268
|
});
|
|
263
269
|
});
|
|
264
270
|
|
|
271
|
+
it("renders tools.mcpApp for a tool call with a ui:// resource", async () => {
|
|
272
|
+
const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
|
|
273
|
+
h.state.message.content = [
|
|
274
|
+
{
|
|
275
|
+
type: "tool-call",
|
|
276
|
+
toolName: "show_chart",
|
|
277
|
+
toolCallId: "c1",
|
|
278
|
+
mcp: { app: { resourceUri: "ui://chart" } },
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
h.state.tools.mcpApp = { render: Mcp };
|
|
282
|
+
await mount();
|
|
283
|
+
expect(container.querySelector('[data-testid="mcp"]')?.textContent).toBe(
|
|
284
|
+
"mcp",
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("prefers a named tool UI over tools.mcpApp", async () => {
|
|
289
|
+
const NamedTool = vi.fn(() => <span data-testid="named">named</span>);
|
|
290
|
+
const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
|
|
291
|
+
h.state.message.content = [
|
|
292
|
+
{
|
|
293
|
+
type: "tool-call",
|
|
294
|
+
toolName: "show_chart",
|
|
295
|
+
toolCallId: "c1",
|
|
296
|
+
mcp: { app: { resourceUri: "ui://chart" } },
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
h.state.tools.toolUIs = { show_chart: [{ render: NamedTool }] };
|
|
300
|
+
h.state.tools.mcpApp = { render: Mcp };
|
|
301
|
+
await mount();
|
|
302
|
+
expect(container.querySelector('[data-testid="named"]')).not.toBeNull();
|
|
303
|
+
expect(Mcp).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("does not use tools.mcpApp when the resource URI is not ui://", async () => {
|
|
307
|
+
const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
|
|
308
|
+
h.state.message.content = [
|
|
309
|
+
{
|
|
310
|
+
type: "tool-call",
|
|
311
|
+
toolName: "show_chart",
|
|
312
|
+
toolCallId: "c1",
|
|
313
|
+
mcp: { app: { resourceUri: "https://example.com/chart" } },
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
h.state.tools.mcpApp = { render: Mcp };
|
|
317
|
+
const renderToolCall = vi.fn(({ part, index }): ReactElement => (
|
|
318
|
+
<span data-testid="fallback">
|
|
319
|
+
fallback:{String(part.toolName)}:{index}
|
|
320
|
+
</span>
|
|
321
|
+
));
|
|
322
|
+
await mount({ renderToolCall });
|
|
323
|
+
expect(
|
|
324
|
+
container.querySelector('[data-testid="fallback"]'),
|
|
325
|
+
).not.toBeNull();
|
|
326
|
+
expect(Mcp).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
265
329
|
it("renders null when no renderer is registered and no fallback is given", async () => {
|
|
266
330
|
h.state.message.content = [
|
|
267
331
|
{ type: "tool-call", toolName: "search", toolCallId: "c1" },
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { type ReactElement, Fragment, useMemo } from "react";
|
|
2
2
|
import { Text } from "react-native";
|
|
3
|
-
import { getMessagePartKeys } from "@assistant-ui/core/internal";
|
|
4
3
|
import type {
|
|
5
4
|
ThreadUserMessagePart,
|
|
6
5
|
ThreadAssistantMessagePart,
|
|
7
6
|
MessagePartState,
|
|
8
7
|
} from "@assistant-ui/core";
|
|
9
8
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
import {
|
|
10
|
+
PartByIndexProvider,
|
|
11
|
+
type ToolCallMessagePartProps,
|
|
12
|
+
type DataMessagePartProps,
|
|
13
13
|
} from "@assistant-ui/core/react";
|
|
14
|
+
import { DefaultMessageImage } from "./MessageParts";
|
|
15
|
+
import {
|
|
16
|
+
getMessagePartKeys,
|
|
17
|
+
resolveToolRender,
|
|
18
|
+
} from "@assistant-ui/core/internal";
|
|
14
19
|
|
|
15
20
|
type MessageContentPart = ThreadUserMessagePart | ThreadAssistantMessagePart;
|
|
16
21
|
type MessageContentStatePart = MessagePartState;
|
|
@@ -69,9 +74,7 @@ const ToolUIDisplay = ({
|
|
|
69
74
|
index: number;
|
|
70
75
|
}) => {
|
|
71
76
|
const aui = useAui();
|
|
72
|
-
const Render = useAuiState(
|
|
73
|
-
(s) => s.tools.toolUIs[part.toolName]?.[0]?.render,
|
|
74
|
-
);
|
|
77
|
+
const Render = useAuiState((s) => resolveToolRender(s.tools, part));
|
|
75
78
|
|
|
76
79
|
const partMethods = useMemo(() => aui.message.part({ index }), [aui, index]);
|
|
77
80
|
|
|
@@ -153,9 +156,16 @@ export const MessageContent = ({
|
|
|
153
156
|
</Fragment>
|
|
154
157
|
);
|
|
155
158
|
case "image":
|
|
156
|
-
if (!renderImage) return null;
|
|
157
159
|
return (
|
|
158
|
-
<Fragment key={key}>
|
|
160
|
+
<Fragment key={key}>
|
|
161
|
+
{renderImage ? (
|
|
162
|
+
renderImage({ part, index })
|
|
163
|
+
) : (
|
|
164
|
+
<PartByIndexProvider index={index}>
|
|
165
|
+
<DefaultMessageImage />
|
|
166
|
+
</PartByIndexProvider>
|
|
167
|
+
)}
|
|
168
|
+
</Fragment>
|
|
159
169
|
);
|
|
160
170
|
case "reasoning":
|
|
161
171
|
if (!renderReasoning) return null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { act } from "react";
|
|
2
2
|
import { createRoot, type Root } from "react-dom/client";
|
|
3
3
|
import { Text } from "react-native";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { ThreadMessageLike } from "@assistant-ui/core";
|
|
6
6
|
import {
|
|
7
7
|
AssistantRuntimeProvider,
|
|
@@ -10,6 +10,23 @@ import {
|
|
|
10
10
|
} from "@assistant-ui/core/react";
|
|
11
11
|
import { MessagePrimitiveParts } from "./MessageParts";
|
|
12
12
|
|
|
13
|
+
const h = vi.hoisted(() => ({
|
|
14
|
+
imageProps: null as Record<string, unknown> | null,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("react-native", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("react-native")>();
|
|
19
|
+
const React = await import("react");
|
|
20
|
+
const ImageMock = (props: Record<string, unknown>) => {
|
|
21
|
+
h.imageProps = props;
|
|
22
|
+
return React.createElement(
|
|
23
|
+
actual.Image as unknown as React.ElementType,
|
|
24
|
+
props,
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
return { ...actual, Image: ImageMock };
|
|
28
|
+
});
|
|
29
|
+
|
|
13
30
|
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
31
|
|
|
15
32
|
const messages: ThreadMessageLike[] = [
|
|
@@ -73,11 +90,33 @@ const App = (props: MessagePrimitiveParts.Props) => {
|
|
|
73
90
|
|
|
74
91
|
const ChainOfThought = () => <Text>Thought group</Text>;
|
|
75
92
|
|
|
93
|
+
const IMAGE = "data:image/png;base64,iVBORw0KGgo=";
|
|
94
|
+
|
|
95
|
+
const ImageApp = (props: MessagePrimitiveParts.Props) => {
|
|
96
|
+
const runtime = useExternalStoreRuntime({
|
|
97
|
+
messages: [
|
|
98
|
+
{ role: "assistant", content: [{ type: "image", image: IMAGE }] },
|
|
99
|
+
] satisfies ThreadMessageLike[],
|
|
100
|
+
convertMessage: (message) => message,
|
|
101
|
+
onNew: async () => {
|
|
102
|
+
throw new Error("This thread is read-only");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
return (
|
|
106
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
107
|
+
<MessageByIndexProvider index={0}>
|
|
108
|
+
<MessagePrimitiveParts {...props} />
|
|
109
|
+
</MessageByIndexProvider>
|
|
110
|
+
</AssistantRuntimeProvider>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
76
114
|
describe("MessagePrimitiveParts", () => {
|
|
77
115
|
let container: HTMLDivElement;
|
|
78
116
|
let root: Root;
|
|
79
117
|
|
|
80
118
|
beforeEach(() => {
|
|
119
|
+
h.imageProps = null;
|
|
81
120
|
container = document.createElement("div");
|
|
82
121
|
document.body.appendChild(container);
|
|
83
122
|
root = createRoot(container);
|
|
@@ -88,6 +127,34 @@ describe("MessagePrimitiveParts", () => {
|
|
|
88
127
|
container.remove();
|
|
89
128
|
});
|
|
90
129
|
|
|
130
|
+
it("renders an image part without a components override", async () => {
|
|
131
|
+
await act(async () => root.render(<ImageApp />));
|
|
132
|
+
|
|
133
|
+
const image = container.querySelector("img");
|
|
134
|
+
expect(image).not.toBeNull();
|
|
135
|
+
expect(image?.getAttribute("src")).toContain(IMAGE);
|
|
136
|
+
expect(h.imageProps?.source).toEqual({ uri: IMAGE });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("gives the default image a sizing style, which native layout requires", async () => {
|
|
140
|
+
await act(async () => root.render(<ImageApp />));
|
|
141
|
+
|
|
142
|
+
expect(h.imageProps?.style).toEqual({ width: "100%", aspectRatio: 1 });
|
|
143
|
+
expect(h.imageProps?.resizeMode).toBe("contain");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lets a caller-supplied Image win over the default", async () => {
|
|
147
|
+
const CustomImage = () => <Text>custom image</Text>;
|
|
148
|
+
|
|
149
|
+
await act(async () =>
|
|
150
|
+
root.render(<ImageApp components={{ Image: CustomImage }} />),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(container.textContent).toBe("custom image");
|
|
154
|
+
expect(container.querySelector("img")).toBeNull();
|
|
155
|
+
expect(h.imageProps).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
91
158
|
it("renders generative UI and its fallback beside native text", async () => {
|
|
92
159
|
await act(async () => root.render(<App components={components} />));
|
|
93
160
|
expect(container.textContent).toBe(
|
|
@@ -6,10 +6,22 @@ import {
|
|
|
6
6
|
MessagePrimitivePartByIndex as MessagePrimitivePartByIndexBase,
|
|
7
7
|
messagePartsDefaultComponents,
|
|
8
8
|
} from "@assistant-ui/core/react";
|
|
9
|
+
import { MessagePartPrimitiveImage } from "../messagePart/MessagePartImage";
|
|
10
|
+
|
|
11
|
+
// React Native derives no intrinsic size from a remote or data URI, so an
|
|
12
|
+
// Image with no dimensions lays out at zero. The default fills the available
|
|
13
|
+
// width and keeps the picture whole inside a square box; a consumer that knows
|
|
14
|
+
// its own aspect ratio overrides `components.Image`.
|
|
15
|
+
const DEFAULT_IMAGE_STYLE = { width: "100%", aspectRatio: 1 } as const;
|
|
16
|
+
|
|
17
|
+
export const DefaultMessageImage = () => (
|
|
18
|
+
<MessagePartPrimitiveImage style={DEFAULT_IMAGE_STYLE} resizeMode="contain" />
|
|
19
|
+
);
|
|
9
20
|
|
|
10
21
|
const rnDefaultComponents = {
|
|
11
22
|
...messagePartsDefaultComponents,
|
|
12
23
|
Text: ({ text }: { text: string }) => <Text>{text}</Text>,
|
|
24
|
+
Image: DefaultMessageImage,
|
|
13
25
|
} satisfies MessagePrimitiveParts.Props["components"];
|
|
14
26
|
|
|
15
27
|
export namespace MessagePrimitiveParts {
|
|
@@ -30,7 +42,11 @@ export const MessagePrimitiveParts: FC<MessagePrimitiveParts.Props> = (
|
|
|
30
42
|
|
|
31
43
|
const { components, ...rest } = props;
|
|
32
44
|
const merged = components
|
|
33
|
-
? {
|
|
45
|
+
? {
|
|
46
|
+
...components,
|
|
47
|
+
Text: components.Text ?? rnDefaultComponents.Text,
|
|
48
|
+
Image: components.Image ?? rnDefaultComponents.Image,
|
|
49
|
+
}
|
|
34
50
|
: rnDefaultComponents;
|
|
35
51
|
|
|
36
52
|
return <MessagePrimitivePartsBase components={merged as any} {...rest} />;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import type { ThreadListItemPrimitive } from "../../index";
|
|
6
|
+
import { Title as ThreadListItemTitle } from "../threadListItem";
|
|
7
|
+
|
|
8
|
+
const fallbackProps: ThreadListItemPrimitive.Title.Props = {
|
|
9
|
+
fallback: "New chat",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const h = vi.hoisted(() => {
|
|
13
|
+
const listeners = new Set<() => void>();
|
|
14
|
+
let state = { threadListItem: { title: undefined as string | undefined } };
|
|
15
|
+
return {
|
|
16
|
+
getState: () => state,
|
|
17
|
+
subscribe: (listener: () => void) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
setTitle: (title: string | undefined) => {
|
|
22
|
+
state = { threadListItem: { title } };
|
|
23
|
+
listeners.forEach((listener) => listener());
|
|
24
|
+
},
|
|
25
|
+
textChildren: [] as unknown[],
|
|
26
|
+
textProps: null as Record<string, unknown> | null,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
32
|
+
const { useSyncExternalStore } = await import("react");
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
useAuiState: <T,>(selector: (s: ReturnType<typeof h.getState>) => T) =>
|
|
36
|
+
selector(useSyncExternalStore(h.subscribe, h.getState)),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock("react-native", async (importOriginal) => {
|
|
41
|
+
const actual = await importOriginal<typeof import("react-native")>();
|
|
42
|
+
const React = await import("react");
|
|
43
|
+
const TextMock = (props: Record<string, unknown>) => {
|
|
44
|
+
h.textChildren.push(props.children);
|
|
45
|
+
h.textProps = props;
|
|
46
|
+
return React.createElement(
|
|
47
|
+
actual.Text as unknown as React.ElementType,
|
|
48
|
+
props,
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
return { ...actual, Text: TextMock };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
55
|
+
|
|
56
|
+
describe("ThreadListItemTitle", () => {
|
|
57
|
+
let container: HTMLDivElement;
|
|
58
|
+
let root: Root;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
h.setTitle(undefined);
|
|
62
|
+
h.textChildren = [];
|
|
63
|
+
h.textProps = null;
|
|
64
|
+
container = document.createElement("div");
|
|
65
|
+
document.body.appendChild(container);
|
|
66
|
+
root = createRoot(container);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await act(async () => {
|
|
71
|
+
root.unmount();
|
|
72
|
+
});
|
|
73
|
+
container.remove();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders the title through react-native Text", async () => {
|
|
77
|
+
h.setTitle("My thread");
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
root.render(<ThreadListItemTitle />);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(h.textChildren).toEqual(["My thread"]);
|
|
84
|
+
expect(container.textContent).toBe("My thread");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("forwards host Text props the public type exposes", async () => {
|
|
88
|
+
h.setTitle("My thread");
|
|
89
|
+
|
|
90
|
+
await act(async () => {
|
|
91
|
+
root.render(<ThreadListItemTitle numberOfLines={1} testID="title" />);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(h.textProps).toMatchObject({ numberOfLines: 1, testID: "title" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders a nested Text fallback the ReactNode contract permits", async () => {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
root.render(
|
|
100
|
+
<ThreadListItemTitle fallback={<Text>Untitled chat</Text>} />,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(container.textContent).toBe("Untitled chat");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("renders nothing without a title or fallback", async () => {
|
|
108
|
+
await act(async () => {
|
|
109
|
+
root.render(<ThreadListItemTitle numberOfLines={1} />);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(h.textChildren).toEqual([]);
|
|
113
|
+
expect(container.innerHTML).toBe("");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders an element fallback outside Text", async () => {
|
|
117
|
+
await act(async () => {
|
|
118
|
+
root.render(
|
|
119
|
+
<ThreadListItemTitle fallback={<View testID="fallback-view" />} />,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(h.textChildren).toEqual([]);
|
|
124
|
+
expect(
|
|
125
|
+
container.querySelector('[data-testid="fallback-view"]'),
|
|
126
|
+
).not.toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the fallback through Text when there is no title", async () => {
|
|
130
|
+
await act(async () => {
|
|
131
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(h.textChildren).toEqual(["New chat"]);
|
|
135
|
+
expect(container.textContent).toBe("New chat");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders an empty title with the fallback", async () => {
|
|
139
|
+
h.setTitle("");
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(container.textContent).toBe("New chat");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("updates the title while mounted", async () => {
|
|
149
|
+
await act(async () => {
|
|
150
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
151
|
+
});
|
|
152
|
+
expect(container.textContent).toBe("New chat");
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
h.setTitle("First title");
|
|
156
|
+
});
|
|
157
|
+
expect(container.textContent).toBe("First title");
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
h.setTitle("Updated title");
|
|
161
|
+
});
|
|
162
|
+
expect(container.textContent).toBe("Updated title");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Text, type TextProps } from "react-native";
|
|
3
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
4
|
+
|
|
5
|
+
export type ThreadListItemTitleProps = Omit<TextProps, "children"> & {
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ThreadListItemTitle({
|
|
10
|
+
fallback,
|
|
11
|
+
...props
|
|
12
|
+
}: ThreadListItemTitleProps) {
|
|
13
|
+
const title = useAuiState((s) => s.threadListItem.title);
|
|
14
|
+
const content = title || fallback;
|
|
15
|
+
if (typeof content === "string" || typeof content === "number") {
|
|
16
|
+
return <Text {...props}>{content}</Text>;
|
|
17
|
+
}
|
|
18
|
+
return <>{content}</>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export namespace ThreadListItemTitle {
|
|
22
|
+
export type Props = ThreadListItemTitleProps;
|
|
23
|
+
}
|
|
@@ -2,7 +2,10 @@ export {
|
|
|
2
2
|
ThreadListItemRoot as Root,
|
|
3
3
|
type ThreadListItemRootProps as RootProps,
|
|
4
4
|
} from "./threadListItem/ThreadListItemRoot";
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
ThreadListItemTitle as Title,
|
|
7
|
+
type ThreadListItemTitleProps as TitleProps,
|
|
8
|
+
} from "./threadListItem/ThreadListItemTitle";
|
|
6
9
|
export {
|
|
7
10
|
ThreadListItemTrigger as Trigger,
|
|
8
11
|
type ThreadListItemTriggerProps as TriggerProps,
|