Skip to content

Commit 7298dc6

Browse files
mschristensenclaude
andcommitted
demo: per-view debug panes, lazy view creation, React key fix
Each split pane now includes its own inline DebugPane showing view-scoped Ably messages and UIMessages. A new useViewAblyMessages hook subscribes to the View's scoped 'ably-message' event. The second View is now created lazily when split mode activates and closed on deactivation or unmount, fixing a resource leak. DebugPane refactored with an `inline` prop — inline mode renders directly within the pane, non-inline mode keeps the collapsible sidebar. React keys use Ably serial instead of array index. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41fe283 commit 7298dc6

4 files changed

Lines changed: 195 additions & 73 deletions

File tree

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

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

33
import type { UIMessage, UIMessageChunk } from 'ai';
44
import type { ViewHandle } from '@ably/ai-transport/react';
5-
import type { ClientTransport } from '@ably/ai-transport';
5+
import type { ClientTransport, View } from '@ably/ai-transport';
66
import { MessageList } from './message-list';
77
import { InputBar } from './input-bar';
88
import { MessageQueue } from './message-queue';
9+
import { DebugPane } from './debug-pane';
910
import { useMessageQueue } from '../hooks/use-message-queue';
11+
import { useViewAblyMessages } from '../hooks/use-view-ably-messages';
1012
import { userMessage } from '../helpers';
1113

1214
interface ChatPaneProps {
1315
label: string;
1416
transport: ClientTransport<UIMessageChunk, UIMessage>;
17+
/** The raw View — used for subscribing to scoped ably-message events. */
18+
rawView: View<UIMessageChunk, UIMessage>;
19+
/** The reactive ViewHandle from useView — used for rendering and write ops. */
1520
view: ViewHandle<UIMessageChunk, UIMessage>;
1621
activeTurns: Map<string, Set<string>>;
1722
clientId: string | undefined;
1823
}
1924

20-
export function ChatPane({ label, transport, view, activeTurns, clientId }: ChatPaneProps) {
25+
export function ChatPane({ label, transport, rawView, view, activeTurns, clientId }: ChatPaneProps) {
2126
const queue = useMessageQueue(transport, view.send);
27+
const ablyMessages = useViewAblyMessages(rawView);
2228

2329
return (
24-
<div className="flex flex-1 flex-col min-w-0">
25-
<div className="flex items-center gap-2 border-b border-zinc-800 px-3 py-1.5">
26-
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{label}</span>
30+
<div className="flex flex-1 min-w-0 min-h-0">
31+
<div className="flex flex-1 flex-col min-w-0">
32+
<div className="flex items-center gap-2 border-b border-zinc-800 px-3 py-1.5">
33+
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{label}</span>
34+
</div>
35+
<MessageList
36+
view={view}
37+
onRegenerate={(id) => view.regenerate(id)}
38+
onEdit={(id, text) => view.edit(id, [userMessage(text)])}
39+
/>
40+
<MessageQueue queue={queue} />
41+
<InputBar
42+
transport={transport}
43+
send={view.send}
44+
activeTurns={activeTurns}
45+
clientId={clientId}
46+
queue={queue}
47+
/>
2748
</div>
28-
<MessageList
29-
view={view}
30-
onRegenerate={(id) => view.regenerate(id)}
31-
onEdit={(id, text) => view.edit(id, [userMessage(text)])}
32-
/>
33-
<MessageQueue queue={queue} />
34-
<InputBar
35-
transport={transport}
36-
send={view.send}
49+
<DebugPane
50+
messages={view.nodes.map((n) => n.message)}
51+
ablyMessages={ablyMessages}
3752
activeTurns={activeTurns}
38-
clientId={clientId}
39-
queue={queue}
53+
inline
4054
/>
4155
</div>
4256
);

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

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use client';
22

3-
import { useState, useMemo } from 'react';
3+
import { useState, useEffect, useRef } from 'react';
44
import { useChannel } from 'ably/react';
55
import { useClientTransport, useActiveTurns, useView, useAblyMessages } from '@ably/ai-transport/react';
6+
import type { View } from '@ably/ai-transport';
7+
import type { UIMessageChunk, UIMessage } from 'ai';
68
import { UIMessageCodec } from '@ably/ai-transport/vercel';
79

810
import { userMessage } from '../helpers';
@@ -20,6 +22,31 @@ interface ChatProps {
2022
historyLimit?: number;
2123
}
2224

25+
/**
26+
* Create a secondary View lazily when split mode is active, and close it
27+
* when split mode is deactivated or the component unmounts.
28+
*/
29+
function useSecondView(transport: ReturnType<typeof useClientTransport>, split: boolean, historyLimit: number) {
30+
const viewRef = useRef<View<UIMessageChunk, UIMessage> | null>(null);
31+
32+
// Create or close the raw view when split changes
33+
useEffect(() => {
34+
if (split && !viewRef.current) {
35+
viewRef.current = transport.createView();
36+
}
37+
return () => {
38+
if (viewRef.current) {
39+
viewRef.current.close();
40+
viewRef.current = null;
41+
}
42+
};
43+
}, [split, transport]);
44+
45+
const rawView = split ? viewRef.current : null;
46+
const handle = useView(rawView, rawView ? { limit: historyLimit } : null);
47+
return { rawView, handle };
48+
}
49+
2350
export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
2451
const { channel } = useChannel({ channelName: chatId });
2552
const [split, setSplit] = useState(false);
@@ -31,9 +58,9 @@ export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
3158
body: () => ({ id: chatId }),
3259
});
3360

34-
const view = useView(transport, { limit: historyLimit ?? 30 });
35-
const secondView = useMemo(() => transport.createView(), [transport]);
36-
const view2 = useView(secondView, { limit: historyLimit ?? 30 });
61+
const limit = historyLimit ?? 30;
62+
const view = useView(transport, { limit });
63+
const { rawView: secondRawView, handle: view2 } = useSecondView(transport, split, limit);
3764

3865
const activeTurns = useActiveTurns(transport);
3966
const ablyMessages = useAblyMessages(transport);
@@ -52,18 +79,22 @@ export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
5279
<ChatPane
5380
label="View A"
5481
transport={transport}
82+
rawView={transport.view}
5583
view={view}
5684
activeTurns={activeTurns}
5785
clientId={clientId}
5886
/>
5987
<div className="w-px bg-zinc-800" />
60-
<ChatPane
61-
label="View B"
62-
transport={transport}
63-
view={view2}
64-
activeTurns={activeTurns}
65-
clientId={clientId}
66-
/>
88+
{secondRawView && (
89+
<ChatPane
90+
label="View B"
91+
transport={transport}
92+
rawView={secondRawView}
93+
view={view2}
94+
activeTurns={activeTurns}
95+
clientId={clientId}
96+
/>
97+
)}
6798
</div>
6899
) : (
69100
<>
@@ -83,11 +114,13 @@ export function Chat({ chatId, clientId, historyLimit }: ChatProps) {
83114
</>
84115
)}
85116
</div>
86-
<DebugPane
87-
messages={view.nodes.map((n) => n.message)}
88-
ablyMessages={ablyMessages}
89-
activeTurns={activeTurns}
90-
/>
117+
{!split && (
118+
<DebugPane
119+
messages={view.nodes.map((n) => n.message)}
120+
ablyMessages={ablyMessages}
121+
activeTurns={activeTurns}
122+
/>
123+
)}
91124
</div>
92125
);
93126
}

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

Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ interface DebugPaneProps {
88
messages: UIMessage[];
99
ablyMessages: Ably.InboundMessage[];
1010
activeTurns: Map<string, Set<string>>;
11+
/** When true, render inline (no fixed toggle button). Used in split-pane mode. */
12+
inline?: boolean;
1113
}
1214

1315
type Tab = 'ably' | 'uimessages';
@@ -36,9 +38,10 @@ function AblyMessagesTab({ entries }: { entries: Ably.InboundMessage[] }) {
3638
)}
3739
{entries.map((entry, idx) => {
3840
const headers = extractHeaders(entry);
41+
const key = entry.serial ?? `idx-${String(idx)}`;
3942
return (
4043
<div
41-
key={idx}
44+
key={key}
4245
className="rounded border border-zinc-800 bg-zinc-900/50 p-2 text-[11px] font-mono"
4346
>
4447
<div className="flex items-center gap-2 text-zinc-500 mb-1">
@@ -114,10 +117,80 @@ function UIMessagesTab({ messages, activeTurns }: { messages: UIMessage[]; activ
114117
);
115118
}
116119

117-
export function DebugPane({ messages, ablyMessages, activeTurns }: DebugPaneProps) {
118-
const [isOpen, setIsOpen] = useState(false);
120+
function DebugContent({ messages, ablyMessages, activeTurns, onClose }: DebugPaneProps & { onClose?: () => void }) {
119121
const [tab, setTab] = useState<Tab>('ably');
120122

123+
return (
124+
<div className="w-[420px] flex-shrink-0 border-l border-zinc-800 flex flex-col bg-zinc-950">
125+
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
126+
<div className="flex items-center gap-1">
127+
<button
128+
onClick={() => setTab('ably')}
129+
className={`text-[10px] px-2 py-1 rounded transition-colors ${
130+
tab === 'ably' ? 'bg-zinc-800 text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
131+
}`}
132+
>
133+
Ably Messages
134+
<span className="ml-1 text-zinc-600">{ablyMessages.length}</span>
135+
</button>
136+
<button
137+
onClick={() => setTab('uimessages')}
138+
className={`text-[10px] px-2 py-1 rounded transition-colors ${
139+
tab === 'uimessages' ? 'bg-zinc-800 text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
140+
}`}
141+
>
142+
UIMessages
143+
<span className="ml-1 text-zinc-600">{messages.length}</span>
144+
</button>
145+
</div>
146+
{onClose && (
147+
<button
148+
onClick={onClose}
149+
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
150+
>
151+
close
152+
</button>
153+
)}
154+
</div>
155+
{tab === 'ably' ? (
156+
<AblyMessagesTab entries={ablyMessages} />
157+
) : (
158+
<UIMessagesTab
159+
messages={messages}
160+
activeTurns={activeTurns}
161+
/>
162+
)}
163+
</div>
164+
);
165+
}
166+
167+
export function DebugPane({ messages, ablyMessages, activeTurns, inline }: DebugPaneProps) {
168+
const [isOpen, setIsOpen] = useState(false);
169+
170+
if (inline) {
171+
return (
172+
<>
173+
{!isOpen && (
174+
<button
175+
onClick={() => setIsOpen(true)}
176+
className="border-l border-zinc-800 bg-zinc-950 px-1.5 text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
177+
title="Show debug pane"
178+
>
179+
&lsaquo;
180+
</button>
181+
)}
182+
{isOpen && (
183+
<DebugContent
184+
messages={messages}
185+
ablyMessages={ablyMessages}
186+
activeTurns={activeTurns}
187+
onClose={() => setIsOpen(false)}
188+
/>
189+
)}
190+
</>
191+
);
192+
}
193+
121194
return (
122195
<>
123196
{!isOpen && (
@@ -131,44 +204,12 @@ export function DebugPane({ messages, ablyMessages, activeTurns }: DebugPaneProp
131204
)}
132205

133206
{isOpen && (
134-
<div className="w-[420px] flex-shrink-0 border-l border-zinc-800 flex flex-col bg-zinc-950">
135-
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
136-
<div className="flex items-center gap-1">
137-
<button
138-
onClick={() => setTab('ably')}
139-
className={`text-[10px] px-2 py-1 rounded transition-colors ${
140-
tab === 'ably' ? 'bg-zinc-800 text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
141-
}`}
142-
>
143-
Ably Messages
144-
<span className="ml-1 text-zinc-600">{ablyMessages.length}</span>
145-
</button>
146-
<button
147-
onClick={() => setTab('uimessages')}
148-
className={`text-[10px] px-2 py-1 rounded transition-colors ${
149-
tab === 'uimessages' ? 'bg-zinc-800 text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
150-
}`}
151-
>
152-
UIMessages
153-
<span className="ml-1 text-zinc-600">{messages.length}</span>
154-
</button>
155-
</div>
156-
<button
157-
onClick={() => setIsOpen(false)}
158-
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
159-
>
160-
close
161-
</button>
162-
</div>
163-
{tab === 'ably' ? (
164-
<AblyMessagesTab entries={ablyMessages} />
165-
) : (
166-
<UIMessagesTab
167-
messages={messages}
168-
activeTurns={activeTurns}
169-
/>
170-
)}
171-
</div>
207+
<DebugContent
208+
messages={messages}
209+
ablyMessages={ablyMessages}
210+
activeTurns={activeTurns}
211+
onClose={() => setIsOpen(false)}
212+
/>
172213
)}
173214
</>
174215
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
/**
4+
* useViewAblyMessages — accumulates raw Ably messages scoped to a View.
5+
*
6+
* Subscribes to the View's 'ably-message' event, which only fires for
7+
* messages corresponding to visible nodes in that view's window.
8+
*/
9+
10+
import type * as Ably from 'ably';
11+
import { useEffect, useRef, useState } from 'react';
12+
import type { View } from '@ably/ai-transport';
13+
14+
export const useViewAblyMessages = <TEvent, TMessage>(
15+
view: View<TEvent, TMessage> | null | undefined,
16+
): Ably.InboundMessage[] => {
17+
const [messages, setMessages] = useState<Ably.InboundMessage[]>([]);
18+
const messagesRef = useRef<Ably.InboundMessage[]>([]);
19+
20+
useEffect(() => {
21+
if (!view) return;
22+
messagesRef.current = [];
23+
setMessages([]);
24+
25+
const unsub = view.on('ably-message', (msg: Ably.InboundMessage) => {
26+
const next = [...messagesRef.current, msg];
27+
messagesRef.current = next;
28+
setMessages(next);
29+
});
30+
return unsub;
31+
}, [view]);
32+
33+
return messages;
34+
};

0 commit comments

Comments
 (0)