Skip to content

Commit 7cf750e

Browse files
committed
transport: replace MessageWithHeaders with ConversationNode
Eliminate the lossy MessageWithHeaders projection in favour of the richer ConversationNode type on both read and write paths. Read path: getMessagesWithHeaders() is replaced by getNodes(), which returns ConversationNode[] directly from the conversation tree instead of stripping fields into {message, headers?}. Write path: Turn.addMessages() now accepts ConversationNode[] instead of MessageWithHeaders[]. The server uses typed node fields (msgId, parentId, forkOf) directly rather than generating UUIDs and overriding via raw header strings. AddMessageOptions is simplified to just clientId. Consumers get typed msgId, parentId, forkOf, and non-optional headers instead of digging into raw header strings — this also eliminates the ! non-null assertion that was in the demo code. Tests add coverage for node parentId/forkOf flowing through server transport headers.
1 parent b43799e commit 7cf750e

23 files changed

Lines changed: 221 additions & 225 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,13 @@ import type { UIMessage } from 'ai';
8989
import { anthropic } from '@ai-sdk/anthropic';
9090
import Ably from 'ably';
9191
import { createServerTransport } from '@ably/ai-transport/vercel';
92-
import type { MessageWithHeaders } from '@ably/ai-transport';
92+
import type { ConversationNode } from '@ably/ai-transport';
9393

9494
interface ChatRequestBody {
9595
turnId: string;
9696
clientId: string;
97-
messages: MessageWithHeaders<UIMessage>[];
98-
history?: MessageWithHeaders<UIMessage>[];
97+
messages: ConversationNode<UIMessage>[];
98+
history?: ConversationNode<UIMessage>[];
9999
id: string;
100100
forkOf?: string;
101101
parent?: string | null;

demo/vercel/react/use-chat/src/app/api/chat/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import type { UIMessage } from 'ai';
1212
import { anthropic } from '@ai-sdk/anthropic';
1313
import Ably from 'ably';
1414
import { createServerTransport } from '@ably/ai-transport/vercel';
15-
import type { MessageWithHeaders } from '@ably/ai-transport';
15+
import type { ConversationNode } from '@ably/ai-transport';
1616

1717
/** Shape of the POST body sent by the client transport. */
1818
interface ChatRequestBody {
1919
turnId: string;
2020
clientId: string;
21-
messages: MessageWithHeaders<UIMessage>[];
22-
history?: MessageWithHeaders<UIMessage>[];
21+
messages: ConversationNode<UIMessage>[];
22+
history?: ConversationNode<UIMessage>[];
2323
id: string;
2424
forkOf?: string;
2525
parent?: string | null;

demo/vercel/react/use-chat/src/app/chat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function Chat({ chatId, clientId, historyLimit }: { chatId: string; clien
4040
<div className="flex flex-1 flex-col">
4141
<Header clientId={clientId} />
4242
<MessageList
43-
messagesWithHeaders={transport.getMessagesWithHeaders()}
43+
nodes={transport.getNodes()}
4444
hasNext={history.hasNext}
4545
loading={history.loading}
4646
onNext={() => history.next()}

demo/vercel/react/use-chat/src/app/components/message-list.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,30 @@
22

33
import { useRef, useEffect } from 'react';
44
import type { UIMessage } from 'ai';
5-
import type { MessageWithHeaders } from '@ably/ai-transport';
5+
import type { ConversationNode } from '@ably/ai-transport';
66
import { MessageBubble } from './message-bubble';
77

88
interface MessageListProps {
9-
messagesWithHeaders: MessageWithHeaders<UIMessage>[];
9+
nodes: ConversationNode<UIMessage>[];
1010
hasNext: boolean;
1111
loading: boolean;
1212
onNext: () => void;
1313
onRegenerate: (messageId: string) => void;
1414
}
1515

16-
export function MessageList({ messagesWithHeaders, hasNext, loading, onNext, onRegenerate }: MessageListProps) {
16+
export function MessageList({ nodes, hasNext, loading, onNext, onRegenerate }: MessageListProps) {
1717
const endRef = useRef<HTMLDivElement>(null);
1818
const scrollRef = useRef<HTMLDivElement>(null);
1919
const prevLastIdRef = useRef<string | undefined>(undefined);
2020

2121
useEffect(() => {
2222
const lastId =
23-
messagesWithHeaders.length > 0 ? messagesWithHeaders[messagesWithHeaders.length - 1].message.id : undefined;
23+
nodes.length > 0 ? nodes[nodes.length - 1].message.id : undefined;
2424
if (lastId && lastId !== prevLastIdRef.current) {
2525
prevLastIdRef.current = lastId;
2626
endRef.current?.scrollIntoView({ behavior: 'smooth' });
2727
}
28-
}, [messagesWithHeaders]);
28+
}, [nodes]);
2929

3030
const handleScroll = () => {
3131
const el = scrollRef.current;
@@ -53,10 +53,10 @@ export function MessageList({ messagesWithHeaders, hasNext, loading, onNext, onR
5353
</div>
5454
)}
5555
{loading && <div className="text-center text-xs text-zinc-600 animate-pulse">Loading history...</div>}
56-
{messagesWithHeaders.length === 0 && !loading && (
56+
{nodes.length === 0 && !loading && (
5757
<p className="text-sm text-zinc-600 text-center mt-20">Send a message to start chatting.</p>
5858
)}
59-
{messagesWithHeaders.map(({ message, headers }) => (
59+
{nodes.map(({ message, headers }) => (
6060
<MessageBubble
6161
key={message.id}
6262
message={message}

demo/vercel/react/use-client-transport/src/app/api/chat/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ import type { UIMessage } from 'ai';
1212
import { anthropic } from '@ai-sdk/anthropic';
1313
import Ably from 'ably';
1414
import { createServerTransport } from '@ably/ai-transport/vercel';
15-
import type { MessageWithHeaders } from '@ably/ai-transport';
15+
import type { ConversationNode } from '@ably/ai-transport';
1616

1717
/** Shape of the POST body sent by the client transport. */
1818
interface ChatRequestBody {
1919
turnId: string;
2020
clientId: string;
21-
messages: MessageWithHeaders<UIMessage>[];
22-
history?: MessageWithHeaders<UIMessage>[];
21+
messages: ConversationNode<UIMessage>[];
22+
history?: ConversationNode<UIMessage>[];
2323
id: string;
2424
forkOf?: string;
2525
parent?: string | null;

demo/vercel/react/use-client-transport/src/app/components/chat.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
5151
<div className="flex flex-1 flex-col">
5252
<Header clientId={clientId} />
5353
<MessageList
54-
messagesWithHeaders={transport.getMessagesWithHeaders()}
5554
tree={tree}
5655
hasNext={history.hasNext}
5756
loading={history.loading}

demo/vercel/react/use-client-transport/src/app/components/message-list.tsx

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22

33
import { useRef, useEffect } from 'react';
44
import type { UIMessage } from 'ai';
5-
import type { MessageWithHeaders } from '@ably/ai-transport';
65
import type { ConversationTreeHandle } from '@ably/ai-transport/react';
76
import { MessageBubble } from './message-bubble';
87

98
interface MessageListProps {
10-
messagesWithHeaders: MessageWithHeaders<UIMessage>[];
119
tree: ConversationTreeHandle<UIMessage>;
1210
hasNext: boolean;
1311
loading: boolean;
@@ -17,7 +15,6 @@ interface MessageListProps {
1715
}
1816

1917
export function MessageList({
20-
messagesWithHeaders,
2118
tree,
2219
hasNext,
2320
loading,
@@ -29,15 +26,17 @@ export function MessageList({
2926
const scrollRef = useRef<HTMLDivElement>(null);
3027
const prevLastIdRef = useRef<string | undefined>(undefined);
3128

29+
const { nodes } = tree;
30+
3231
// Auto-scroll to bottom only when the last message changes
3332
useEffect(() => {
3433
const lastId =
35-
messagesWithHeaders.length > 0 ? messagesWithHeaders[messagesWithHeaders.length - 1].message.id : undefined;
34+
nodes.length > 0 ? nodes[nodes.length - 1].message.id : undefined;
3635
if (lastId && lastId !== prevLastIdRef.current) {
3736
prevLastIdRef.current = lastId;
3837
endRef.current?.scrollIntoView({ behavior: 'smooth' });
3938
}
40-
}, [messagesWithHeaders]);
39+
}, [nodes]);
4140

4241
const handleScroll = () => {
4342
const el = scrollRef.current;
@@ -65,25 +64,22 @@ export function MessageList({
6564
</div>
6665
)}
6766
{loading && <div className="text-center text-xs text-zinc-600 animate-pulse">Loading history...</div>}
68-
{messagesWithHeaders.length === 0 && !loading && (
67+
{nodes.length === 0 && !loading && (
6968
<p className="text-sm text-zinc-600 text-center mt-20">Send a message to start chatting.</p>
7069
)}
71-
{messagesWithHeaders.map(({ message, headers }) => {
72-
const msgId = headers!['x-ably-msg-id'];
73-
return (
74-
<MessageBubble
75-
key={message.id}
76-
message={message}
77-
headers={headers}
78-
hasSiblings={tree.hasSiblings(msgId)}
79-
siblings={tree.getSiblings(msgId)}
80-
selectedIndex={tree.getSelectedIndex(msgId)}
81-
onSelectSibling={(index) => tree.selectSibling(msgId, index)}
82-
onRegenerate={message.role === 'assistant' ? () => onRegenerate(msgId) : undefined}
83-
onEdit={message.role === 'user' ? (text) => onEdit(msgId, text) : undefined}
84-
/>
85-
);
86-
})}
70+
{nodes.map((node) => (
71+
<MessageBubble
72+
key={node.message.id}
73+
message={node.message}
74+
headers={node.headers}
75+
hasSiblings={tree.hasSiblings(node.msgId)}
76+
siblings={tree.getSiblings(node.msgId)}
77+
selectedIndex={tree.getSelectedIndex(node.msgId)}
78+
onSelectSibling={(index) => tree.selectSibling(node.msgId, index)}
79+
onRegenerate={node.message.role === 'assistant' ? () => onRegenerate(node.msgId) : undefined}
80+
onEdit={node.message.role === 'user' ? (text) => onEdit(node.msgId, text) : undefined}
81+
/>
82+
))}
8783
<div ref={endRef} />
8884
</div>
8985
);

docs/features/branching.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,9 @@ const tree = useConversationTree(transport);
7474
// tree.getSelectedIndex(nodeId) - which sibling is currently selected
7575
// tree.selectSibling(nodeId, index) - switch to a different sibling
7676
//
77-
// nodeId is the x-ably-msg-id for each message - iterate getMessagesWithHeaders()
78-
// to get messages paired with their headers:
79-
// transport.getMessagesWithHeaders().map(({ message: msg, headers }) => {
80-
// const nodeId = headers?.['x-ably-msg-id'] ?? msg.id;
77+
// nodeId is the msgId on each ConversationNode — iterate getNodes():
78+
// transport.getNodes().map((node) => {
79+
// const nodeId = node.msgId;
8180
// });
8281
```
8382

docs/get-started/vercel-use-chat.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,13 @@ import type { UIMessage } from 'ai';
9494
import { anthropic } from '@ai-sdk/anthropic';
9595
import Ably from 'ably';
9696
import { createServerTransport } from '@ably/ai-transport/vercel';
97-
import type { MessageWithHeaders } from '@ably/ai-transport';
97+
import type { ConversationNode } from '@ably/ai-transport';
9898

9999
interface ChatRequestBody {
100100
turnId: string;
101101
clientId: string;
102-
messages: MessageWithHeaders<UIMessage>[];
103-
history?: MessageWithHeaders<UIMessage>[];
102+
messages: ConversationNode<UIMessage>[];
103+
history?: ConversationNode<UIMessage>[];
104104
id: string;
105105
forkOf?: string;
106106
parent?: string | null;

docs/get-started/vercel-use-client-transport.md

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,6 @@ import { UIMessageCodec } from '@ably/ai-transport/vercel';
2929
import type { UIMessage } from 'ai';
3030
import { useState } from 'react';
3131

32-
// Resolve the x-ably-msg-id for a message. Tree methods and regenerate/edit
33-
// use x-ably-msg-id as the key, not UIMessage.id.
34-
function treeMsgId(headers: Record<string, string> | undefined, msg: UIMessage): string {
35-
return headers?.['x-ably-msg-id'] ?? msg.id;
36-
}
37-
3832
function ChatInner({ chatId, clientId }: { chatId: string; clientId?: string }) {
3933
const { channel } = useChannel({ channelName: chatId });
4034
const [input, setInput] = useState('');
@@ -58,7 +52,7 @@ function ChatInner({ chatId, clientId }: { chatId: string; clientId?: string })
5852

5953
const isStreaming = activeTurns.size > 0;
6054

61-
const messagesWithHeaders = transport.getMessagesWithHeaders();
55+
const nodes = transport.getNodes();
6256

6357
const handleSend = () => {
6458
const text = input.trim();
@@ -83,32 +77,29 @@ function ChatInner({ chatId, clientId }: { chatId: string; clientId?: string })
8377
</button>
8478
)}
8579

86-
{/* Message list with headers for tree navigation */}
87-
{messagesWithHeaders.map(({ message: msg, headers }) => {
88-
const nodeId = treeMsgId(headers, msg);
89-
return (
90-
<div key={msg.id}>
91-
<strong>{msg.role}:</strong>
92-
{msg.parts.map((part, i) => (
93-
part.type === 'text' ? <span key={i}>{part.text}</span> : null
94-
))}
95-
96-
{/* Branch navigation */}
97-
{tree.hasSiblings(nodeId) && (
98-
<span>
99-
{tree.getSelectedIndex(nodeId) + 1} / {tree.getSiblings(nodeId).length}
100-
<button onClick={() => tree.selectSibling(nodeId, tree.getSelectedIndex(nodeId) - 1)}>prev</button>
101-
<button onClick={() => tree.selectSibling(nodeId, tree.getSelectedIndex(nodeId) + 1)}>next</button>
102-
</span>
103-
)}
104-
105-
{/* Regenerate assistant messages */}
106-
{msg.role === 'assistant' && (
107-
<button onClick={() => regenerate(nodeId)}>Regenerate</button>
108-
)}
109-
</div>
110-
);
111-
})}
80+
{/* Message list — each node has a typed msgId for tree navigation */}
81+
{nodes.map((node) => (
82+
<div key={node.message.id}>
83+
<strong>{node.message.role}:</strong>
84+
{node.message.parts.map((part, i) => (
85+
part.type === 'text' ? <span key={i}>{part.text}</span> : null
86+
))}
87+
88+
{/* Branch navigation */}
89+
{tree.hasSiblings(node.msgId) && (
90+
<span>
91+
{tree.getSelectedIndex(node.msgId) + 1} / {tree.getSiblings(node.msgId).length}
92+
<button onClick={() => tree.selectSibling(node.msgId, tree.getSelectedIndex(node.msgId) - 1)}>prev</button>
93+
<button onClick={() => tree.selectSibling(node.msgId, tree.getSelectedIndex(node.msgId) + 1)}>next</button>
94+
</span>
95+
)}
96+
97+
{/* Regenerate assistant messages */}
98+
{node.message.role === 'assistant' && (
99+
<button onClick={() => regenerate(node.msgId)}>Regenerate</button>
100+
)}
101+
</div>
102+
))}
112103

113104
{/* Input */}
114105
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>

0 commit comments

Comments
 (0)