@djangocfg/ui-tools 2.1.457 → 2.1.460
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/package.json +6 -6
- package/src/tools/chat/composer/Composer.tsx +11 -8
- package/src/tools/chat/composer/ComposerButton.tsx +7 -0
- package/src/tools/chat/context/ChatProvider.tsx +19 -0
- package/src/tools/chat/context/__tests__/ChatProvider.streamEndFocus.test.tsx +188 -0
- package/src/tools/chat/core/__tests__/reducer.sessionSwitch.test.ts +151 -0
- package/src/tools/chat/core/index.ts +7 -1
- package/src/tools/chat/core/reducer.ts +68 -5
- package/src/tools/chat/hooks/__tests__/useChat.newSession.test.tsx +123 -0
- package/src/tools/chat/hooks/__tests__/useChat.sessionSwitch.test.tsx +275 -0
- package/src/tools/chat/hooks/useChat.ts +88 -6
- package/src/tools/chat/public.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.460",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -334,8 +334,8 @@
|
|
|
334
334
|
"test:watch": "vitest"
|
|
335
335
|
},
|
|
336
336
|
"peerDependencies": {
|
|
337
|
-
"@djangocfg/i18n": "^2.1.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
337
|
+
"@djangocfg/i18n": "^2.1.460",
|
|
338
|
+
"@djangocfg/ui-core": "^2.1.460",
|
|
339
339
|
"consola": "^3.4.2",
|
|
340
340
|
"lodash-es": "^4.18.1",
|
|
341
341
|
"lucide-react": "^0.545.0",
|
|
@@ -418,9 +418,9 @@
|
|
|
418
418
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
419
419
|
},
|
|
420
420
|
"devDependencies": {
|
|
421
|
-
"@djangocfg/i18n": "^2.1.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
421
|
+
"@djangocfg/i18n": "^2.1.460",
|
|
422
|
+
"@djangocfg/typescript-config": "^2.1.460",
|
|
423
|
+
"@djangocfg/ui-core": "^2.1.460",
|
|
424
424
|
"@types/lodash-es": "^4.17.12",
|
|
425
425
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
426
426
|
"@types/node": "^25.2.3",
|
|
@@ -425,15 +425,18 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
425
425
|
[ref],
|
|
426
426
|
);
|
|
427
427
|
|
|
428
|
-
// Send,
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
428
|
+
// Send, and make sure the caret is in the input. The button itself never
|
|
429
|
+
// takes focus (ComposerButton preventDefaults mousedown), and the textarea
|
|
430
|
+
// stays EDITABLE during the stream (type-ahead by design) — so the caret
|
|
431
|
+
// goes back IMMEDIATELY at send, not after the reply lands. The immediate
|
|
432
|
+
// call covers the case where focus was somewhere else entirely at click
|
|
433
|
+
// time; the `.finally` refocus stays as the tail net for the buffered /
|
|
434
|
+
// error paths (submit resolves at stream end — same edge the provider's
|
|
435
|
+
// stream-end refocus covers). Resolve the live handle from the registry so
|
|
436
|
+
// it works for both the built-in <textarea> and the custom TipTap composer.
|
|
437
|
+
// No-op while the gate is closed (gatedComposer.submit swallows it).
|
|
436
438
|
const handleSend = useCallback(() => {
|
|
439
|
+
getActiveComposer()?.focus?.();
|
|
437
440
|
void Promise.resolve(gatedComposer.submit()).finally(() => {
|
|
438
441
|
requestAnimationFrame(() => getActiveComposer()?.focus?.());
|
|
439
442
|
});
|
|
@@ -41,6 +41,13 @@ export function ComposerButton({ action, size }: ComposerButtonProps) {
|
|
|
41
41
|
<button
|
|
42
42
|
type="button"
|
|
43
43
|
onClick={action.onClick}
|
|
44
|
+
// Action-bar buttons never TAKE focus on click (the editor-toolbar
|
|
45
|
+
// convention): clicking Send/Stop/attach keeps the caret in the
|
|
46
|
+
// composer, so nothing has to "give focus back" afterwards — the
|
|
47
|
+
// deterministic half of the send-refocus UX (the stream-end refocus
|
|
48
|
+
// remains the safety net for focus lost elsewhere). Keyboard focus
|
|
49
|
+
// (Tab) is untouched — this suppresses only the mousedown default.
|
|
50
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
44
51
|
disabled={action.disabled}
|
|
45
52
|
aria-label={action.label}
|
|
46
53
|
title={action.label}
|
|
@@ -62,6 +62,14 @@ const Ctx = createContext<ChatContextValue | null>(null);
|
|
|
62
62
|
export interface ChatProviderProps {
|
|
63
63
|
transport: ChatTransport;
|
|
64
64
|
config?: ChatConfig;
|
|
65
|
+
/**
|
|
66
|
+
* The session to resume on mount. Changing it AFTER mount performs an
|
|
67
|
+
* in-place session switch (atomic `SESSION_SWITCH` + background history
|
|
68
|
+
* revalidate) — the modern alternative to remounting the whole tree via
|
|
69
|
+
* `key={sessionId}`. Pair with a fresh `initialMessages` for the new room
|
|
70
|
+
* to make the swap paint instantly from the host's cache. The legacy
|
|
71
|
+
* remount pattern remains fully supported.
|
|
72
|
+
*/
|
|
65
73
|
initialSessionId?: string;
|
|
66
74
|
/**
|
|
67
75
|
* Warm transcript rendered instantly on mount while the resume-path
|
|
@@ -226,6 +234,17 @@ export function ChatProvider({
|
|
|
226
234
|
// chat appears. Pairs with the stream-end focus above.
|
|
227
235
|
useAutoFocusOnMount({ enabled: autoFocusOnMount });
|
|
228
236
|
|
|
237
|
+
// An IN-PLACE session switch (initialSessionId change, no remount) deserves
|
|
238
|
+
// the same treatment: land the caret in the composer when the room changes,
|
|
239
|
+
// exactly like the legacy remount did via the mount focus above.
|
|
240
|
+
const prevSessionRef = useRef(chat.sessionId);
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (prevSessionRef.current === chat.sessionId) return;
|
|
243
|
+
prevSessionRef.current = chat.sessionId;
|
|
244
|
+
if (!autoFocusOnMount) return;
|
|
245
|
+
getActiveComposer()?.focus();
|
|
246
|
+
}, [chat.sessionId, autoFocusOnMount]);
|
|
247
|
+
|
|
229
248
|
const value = useMemo<ChatContextValue>(
|
|
230
249
|
() => ({
|
|
231
250
|
...chat,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Stream-end + send refocus contract (ChatProvider + composer registry).
|
|
4
|
+
//
|
|
5
|
+
// The full UX has three layers (see Composer.tsx handleSend / ComposerButton):
|
|
6
|
+
// 1. buttons never TAKE focus (mousedown preventDefault) — nothing to give back;
|
|
7
|
+
// 2. handleSend focuses the registered composer immediately at click;
|
|
8
|
+
// 3. ChatProvider's `useStreamEndFocus` refocuses on the streaming → idle edge.
|
|
9
|
+
// This file locks layer 3 — the engine-side edge — plus the focus-on-switch
|
|
10
|
+
// effect, through the SAME registry instance ChatProvider reads
|
|
11
|
+
// (`@djangocfg/ui-tools/composer-registry` — importing the registry by any
|
|
12
|
+
// other path would create a second module instance and test nothing).
|
|
13
|
+
import { act, createElement, useEffect } from 'react';
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
|
|
19
|
+
// jsdom has no matchMedia; ChatProvider's useChatLayout reads it via
|
|
20
|
+
// ui-core's useMediaQuery. A minimal always-false stub is enough.
|
|
21
|
+
if (!window.matchMedia) {
|
|
22
|
+
window.matchMedia = ((query: string) => ({
|
|
23
|
+
matches: false,
|
|
24
|
+
media: query,
|
|
25
|
+
onchange: null,
|
|
26
|
+
addEventListener: () => undefined,
|
|
27
|
+
removeEventListener: () => undefined,
|
|
28
|
+
addListener: () => undefined,
|
|
29
|
+
removeListener: () => undefined,
|
|
30
|
+
dispatchEvent: () => false,
|
|
31
|
+
})) as typeof window.matchMedia;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import { registerComposer } from '@djangocfg/ui-tools/composer-registry';
|
|
35
|
+
import { ChatProvider, useChatContext } from '../ChatProvider';
|
|
36
|
+
import type { ChatMessage } from '../../types';
|
|
37
|
+
import type { ChatTransport } from '../../types/transport';
|
|
38
|
+
import type { HistoryPage, SessionInfo } from '../../types/session';
|
|
39
|
+
|
|
40
|
+
function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
|
|
41
|
+
return { id, role, content, createdAt: 1 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Transport whose stream stays open until the test releases it. */
|
|
45
|
+
function gatedStreamTransport(): { transport: ChatTransport; endStream: () => void } {
|
|
46
|
+
let release!: () => void;
|
|
47
|
+
const gate = new Promise<void>((r) => {
|
|
48
|
+
release = r;
|
|
49
|
+
});
|
|
50
|
+
const transport: ChatTransport = {
|
|
51
|
+
async createSession(): Promise<SessionInfo> {
|
|
52
|
+
return { sessionId: 's1' };
|
|
53
|
+
},
|
|
54
|
+
async loadHistory(): Promise<HistoryPage> {
|
|
55
|
+
return { messages: [], hasMore: false, nextCursor: null };
|
|
56
|
+
},
|
|
57
|
+
async *stream() {
|
|
58
|
+
yield { type: 'chunk' as const, delta: 'hello ' };
|
|
59
|
+
await gate;
|
|
60
|
+
yield { type: 'chunk' as const, delta: 'world' };
|
|
61
|
+
},
|
|
62
|
+
async send(): Promise<ChatMessage> {
|
|
63
|
+
return msg('a', 'a', 'assistant');
|
|
64
|
+
},
|
|
65
|
+
async closeSession() {
|
|
66
|
+
/* unused */
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
return { transport, endStream: release };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function frame(): Promise<void> {
|
|
73
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('ChatProvider — refocus edges', () => {
|
|
77
|
+
let roots: Root[] = [];
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
roots = [];
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
act(() => roots.forEach((r) => r.unmount()));
|
|
83
|
+
registerComposer(null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('focuses the registered composer on the streaming → idle edge', async () => {
|
|
87
|
+
const focus = vi.fn();
|
|
88
|
+
registerComposer({ focus });
|
|
89
|
+
|
|
90
|
+
const { transport, endStream } = gatedStreamTransport();
|
|
91
|
+
let send!: (text: string) => Promise<void>;
|
|
92
|
+
const Grab = () => {
|
|
93
|
+
const chat = useChatContext();
|
|
94
|
+
send = chat.sendMessage;
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
97
|
+
const root = createRoot(document.createElement('div'));
|
|
98
|
+
roots.push(root);
|
|
99
|
+
await act(async () => {
|
|
100
|
+
root.render(
|
|
101
|
+
createElement(
|
|
102
|
+
ChatProvider,
|
|
103
|
+
{ transport, initialSessionId: 's1' },
|
|
104
|
+
createElement(Grab),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let turn!: Promise<void>;
|
|
110
|
+
act(() => {
|
|
111
|
+
turn = send('hi');
|
|
112
|
+
});
|
|
113
|
+
await act(async () => {
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
});
|
|
116
|
+
focus.mockClear(); // ignore any mount-path focus — this test is the EDGE
|
|
117
|
+
|
|
118
|
+
await act(async () => {
|
|
119
|
+
endStream();
|
|
120
|
+
await turn;
|
|
121
|
+
});
|
|
122
|
+
await act(frame); // the stream-end refocus fires on the next frame
|
|
123
|
+
|
|
124
|
+
expect(focus).toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('autoFocusOnStreamEnd={false} leaves focus alone', async () => {
|
|
128
|
+
const focus = vi.fn();
|
|
129
|
+
registerComposer({ focus });
|
|
130
|
+
|
|
131
|
+
const { transport, endStream } = gatedStreamTransport();
|
|
132
|
+
let send!: (text: string) => Promise<void>;
|
|
133
|
+
const Grab = () => {
|
|
134
|
+
const chat = useChatContext();
|
|
135
|
+
send = chat.sendMessage;
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
const root = createRoot(document.createElement('div'));
|
|
139
|
+
roots.push(root);
|
|
140
|
+
await act(async () => {
|
|
141
|
+
root.render(
|
|
142
|
+
createElement(
|
|
143
|
+
ChatProvider,
|
|
144
|
+
{ transport, initialSessionId: 's1', autoFocusOnStreamEnd: false, autoFocusOnMount: false },
|
|
145
|
+
createElement(Grab),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let turn!: Promise<void>;
|
|
151
|
+
act(() => {
|
|
152
|
+
turn = send('hi');
|
|
153
|
+
});
|
|
154
|
+
focus.mockClear();
|
|
155
|
+
await act(async () => {
|
|
156
|
+
endStream();
|
|
157
|
+
await turn;
|
|
158
|
+
});
|
|
159
|
+
await act(frame);
|
|
160
|
+
|
|
161
|
+
expect(focus).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('focuses the composer after an in-place session switch', async () => {
|
|
165
|
+
const focus = vi.fn();
|
|
166
|
+
registerComposer({ focus });
|
|
167
|
+
|
|
168
|
+
const { transport } = gatedStreamTransport();
|
|
169
|
+
const Host = ({ sid }: { sid: string }) =>
|
|
170
|
+
createElement(ChatProvider, { transport, initialSessionId: sid });
|
|
171
|
+
const root = createRoot(document.createElement('div'));
|
|
172
|
+
roots.push(root);
|
|
173
|
+
await act(async () => {
|
|
174
|
+
root.render(createElement(Host, { sid: 'room-a' }));
|
|
175
|
+
});
|
|
176
|
+
focus.mockClear();
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
root.render(createElement(Host, { sid: 'room-b' }));
|
|
180
|
+
});
|
|
181
|
+
await act(frame);
|
|
182
|
+
|
|
183
|
+
expect(focus).toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Silence unused-import lint for useEffect (kept for parity with sibling tests).
|
|
188
|
+
void useEffect;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SESSION_SWITCH + the HISTORY_LOAD_DONE stale-page guard
|
|
2
|
+
// (chat-remount-flicker F2) — the pure state-machine half of the in-place
|
|
3
|
+
// session switch. The hook-level integration lives in
|
|
4
|
+
// hooks/__tests__/useChat.sessionSwitch.test.tsx.
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { initialState, reducer, type ChatState } from '../reducer';
|
|
8
|
+
import type { ChatMessage } from '../../types';
|
|
9
|
+
|
|
10
|
+
function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
|
|
11
|
+
return { id, role, content, createdAt: 1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A lived-in room-A state: transcript, stream flag, error banner, cursor. */
|
|
15
|
+
function roomA(): ChatState {
|
|
16
|
+
return {
|
|
17
|
+
...initialState,
|
|
18
|
+
sessionId: 'room-a',
|
|
19
|
+
messages: [msg('a1', 'hello'), msg('a2', 'world', 'assistant')],
|
|
20
|
+
historyLoaded: true,
|
|
21
|
+
isStreaming: true,
|
|
22
|
+
hasMore: false,
|
|
23
|
+
oldestCursor: 'cur-a',
|
|
24
|
+
error: 'boom',
|
|
25
|
+
errorTurnId: 'a2',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('reducer — SESSION_SWITCH', () => {
|
|
30
|
+
it('seeded switch swaps id + transcript atomically (no loading state)', () => {
|
|
31
|
+
const next = reducer(roomA(), {
|
|
32
|
+
type: 'SESSION_SWITCH',
|
|
33
|
+
sessionId: 'room-b',
|
|
34
|
+
messages: [msg('b1', 'cached b')],
|
|
35
|
+
});
|
|
36
|
+
expect(next.sessionId).toBe('room-b');
|
|
37
|
+
expect(next.messages.map((m) => m.id)).toEqual(['b1']);
|
|
38
|
+
expect(next.historyLoaded).toBe(true);
|
|
39
|
+
expect(next.isLoading).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('unseeded switch enters the loading state with an empty feed', () => {
|
|
43
|
+
const next = reducer(roomA(), { type: 'SESSION_SWITCH', sessionId: 'room-b' });
|
|
44
|
+
expect(next.sessionId).toBe('room-b');
|
|
45
|
+
expect(next.messages).toEqual([]);
|
|
46
|
+
expect(next.historyLoaded).toBe(false);
|
|
47
|
+
expect(next.isLoading).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('resets the OLD room\'s stream / error / paging state wholesale', () => {
|
|
51
|
+
const next = reducer(roomA(), {
|
|
52
|
+
type: 'SESSION_SWITCH',
|
|
53
|
+
sessionId: 'room-b',
|
|
54
|
+
messages: [msg('b1', 'cached b')],
|
|
55
|
+
});
|
|
56
|
+
expect(next.isStreaming).toBe(false);
|
|
57
|
+
expect(next.error).toBeNull();
|
|
58
|
+
expect(next.errorTurnId).toBeNull();
|
|
59
|
+
expect(next.hasMore).toBe(true);
|
|
60
|
+
expect(next.oldestCursor).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('reducer — HISTORY_LOAD_DONE stale-page guard', () => {
|
|
65
|
+
it('drops a page tagged for a session the state already left', () => {
|
|
66
|
+
const state = reducer(roomA(), {
|
|
67
|
+
type: 'SESSION_SWITCH',
|
|
68
|
+
sessionId: 'room-b',
|
|
69
|
+
messages: [msg('b1', 'cached b')],
|
|
70
|
+
});
|
|
71
|
+
const after = reducer(state, {
|
|
72
|
+
type: 'HISTORY_LOAD_DONE',
|
|
73
|
+
sessionId: 'room-a', // slow read for the abandoned room
|
|
74
|
+
messages: [msg('a9', 'stale a page')],
|
|
75
|
+
hasMore: false,
|
|
76
|
+
cursor: null,
|
|
77
|
+
});
|
|
78
|
+
expect(after).toBe(state); // identical reference — nothing committed
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('commits a page tagged for the CURRENT session', () => {
|
|
82
|
+
const state = reducer(roomA(), { type: 'SESSION_SWITCH', sessionId: 'room-b' });
|
|
83
|
+
const after = reducer(state, {
|
|
84
|
+
type: 'HISTORY_LOAD_DONE',
|
|
85
|
+
sessionId: 'room-b',
|
|
86
|
+
messages: [msg('b1', 'fresh b page')],
|
|
87
|
+
hasMore: false,
|
|
88
|
+
cursor: null,
|
|
89
|
+
});
|
|
90
|
+
expect(after.messages.map((m) => m.id)).toEqual(['b1']);
|
|
91
|
+
expect(after.historyLoaded).toBe(true);
|
|
92
|
+
expect(after.isLoading).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('commits an UNTAGGED page unconditionally (legacy dispatch sites)', () => {
|
|
96
|
+
const after = reducer(roomA(), {
|
|
97
|
+
type: 'HISTORY_LOAD_DONE',
|
|
98
|
+
messages: [msg('a3', 'server page')],
|
|
99
|
+
hasMore: true,
|
|
100
|
+
cursor: 'cur-2',
|
|
101
|
+
});
|
|
102
|
+
expect(after.messages.map((m) => m.id)).toEqual(['a3']);
|
|
103
|
+
expect(after.hasMore).toBe(true);
|
|
104
|
+
expect(after.oldestCursor).toBe('cur-2');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('reducer — MESSAGE_INJECT { afterId } (transcript-order splice)', () => {
|
|
109
|
+
it('inserts right after the anchor message', () => {
|
|
110
|
+
const state: ChatState = {
|
|
111
|
+
...initialState,
|
|
112
|
+
sessionId: 's',
|
|
113
|
+
messages: [msg('srv-1', 'old'), msg('u-1', 'my new question')],
|
|
114
|
+
};
|
|
115
|
+
const next = reducer(state, {
|
|
116
|
+
type: 'MESSAGE_INJECT',
|
|
117
|
+
message: msg('srv-2', 'scheduled reminder', 'assistant'),
|
|
118
|
+
position: { afterId: 'srv-1' },
|
|
119
|
+
});
|
|
120
|
+
expect(next.messages.map((m) => m.id)).toEqual(['srv-1', 'srv-2', 'u-1']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back to append when the anchor is not rendered', () => {
|
|
124
|
+
const state: ChatState = {
|
|
125
|
+
...initialState,
|
|
126
|
+
sessionId: 's',
|
|
127
|
+
messages: [msg('u-1', 'hello')],
|
|
128
|
+
};
|
|
129
|
+
const next = reducer(state, {
|
|
130
|
+
type: 'MESSAGE_INJECT',
|
|
131
|
+
message: msg('srv-9', 'late row', 'assistant'),
|
|
132
|
+
position: { afterId: 'gone' },
|
|
133
|
+
});
|
|
134
|
+
expect(next.messages.map((m) => m.id)).toEqual(['u-1', 'srv-9']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('still dedupes by id regardless of position', () => {
|
|
138
|
+
const state: ChatState = {
|
|
139
|
+
...initialState,
|
|
140
|
+
sessionId: 's',
|
|
141
|
+
messages: [msg('srv-1', 'old')],
|
|
142
|
+
};
|
|
143
|
+
const next = reducer(state, {
|
|
144
|
+
type: 'MESSAGE_INJECT',
|
|
145
|
+
message: msg('srv-1', 'old (edited)'),
|
|
146
|
+
position: { afterId: 'srv-1' },
|
|
147
|
+
});
|
|
148
|
+
expect(next.messages).toHaveLength(1);
|
|
149
|
+
expect(next.messages[0].content).toBe('old (edited)');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
reducer,
|
|
3
|
+
initialState,
|
|
4
|
+
type ChatState,
|
|
5
|
+
type ChatAction,
|
|
6
|
+
type MessageInjectPosition,
|
|
7
|
+
} from './reducer';
|
|
2
8
|
export { createId } from './ids';
|
|
3
9
|
export { createTokenBuffer, type TokenBuffer } from './markdown';
|
|
4
10
|
export { resolvePersona, deriveInitials } from './persona';
|
|
@@ -43,6 +43,15 @@ export interface ChatState {
|
|
|
43
43
|
errorTurnId: string | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Where an injected message lands: `append`/`prepend`, or `{ afterId }` —
|
|
48
|
+
* insert right after the message with that id (falls back to append when the
|
|
49
|
+
* anchor isn't rendered). The anchored form lets a history reconcile place
|
|
50
|
+
* server rows in TRANSCRIPT order instead of tacking them onto the end after
|
|
51
|
+
* the client's own newer live turn (the "scheduled message appears last" bug).
|
|
52
|
+
*/
|
|
53
|
+
export type MessageInjectPosition = 'append' | 'prepend' | { afterId: string };
|
|
54
|
+
|
|
46
55
|
export const initialState: ChatState = {
|
|
47
56
|
sessionId: null,
|
|
48
57
|
messages: [],
|
|
@@ -70,6 +79,22 @@ export type ChatAction =
|
|
|
70
79
|
messages: ChatMessage[];
|
|
71
80
|
hasMore: boolean;
|
|
72
81
|
cursor: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* The session this page belongs to. When present and different from
|
|
84
|
+
* `state.sessionId`, the commit is DROPPED — a slow history read for a
|
|
85
|
+
* session the user already switched away from must not clobber the
|
|
86
|
+
* current transcript. Omitted by legacy dispatch sites (applies
|
|
87
|
+
* unconditionally, as before).
|
|
88
|
+
*/
|
|
89
|
+
sessionId?: string;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
type: 'SESSION_SWITCH';
|
|
93
|
+
sessionId: string;
|
|
94
|
+
/** Warm transcript for the NEW session (host cache). When present the
|
|
95
|
+
* feed swaps atomically (`historyLoaded` true, no spinner); when
|
|
96
|
+
* absent the feed enters the loading state until HISTORY_LOAD_DONE. */
|
|
97
|
+
messages?: ChatMessage[];
|
|
73
98
|
}
|
|
74
99
|
| { type: 'HISTORY_MORE_START' }
|
|
75
100
|
| {
|
|
@@ -107,7 +132,7 @@ export type ChatAction =
|
|
|
107
132
|
| { type: 'STREAM_RESUME_EXISTING' }
|
|
108
133
|
| { type: 'MESSAGE_EDIT'; id: string; content: string }
|
|
109
134
|
| { type: 'MESSAGE_DELETE'; id: string }
|
|
110
|
-
| { type: 'MESSAGE_INJECT'; message: ChatMessage; position?:
|
|
135
|
+
| { type: 'MESSAGE_INJECT'; message: ChatMessage; position?: MessageInjectPosition }
|
|
111
136
|
| { type: 'MESSAGE_PATCH'; id: string; patch: Partial<ChatMessage> }
|
|
112
137
|
| { type: 'MESSAGES_CLEAR' }
|
|
113
138
|
| { type: 'ERROR_SET'; error: string | null }
|
|
@@ -119,6 +144,23 @@ export type ChatAction =
|
|
|
119
144
|
status?: ChatAttachment['status'];
|
|
120
145
|
};
|
|
121
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Where a MESSAGE_INJECT lands: `'prepend'` → 0, `{ afterId }` → right after
|
|
149
|
+
* the anchor (append when the anchor isn't rendered — deleted or never
|
|
150
|
+
* loaded), `'append'`/absent → the end.
|
|
151
|
+
*/
|
|
152
|
+
function insertIndexFor(
|
|
153
|
+
position: MessageInjectPosition | undefined,
|
|
154
|
+
messages: ChatMessage[],
|
|
155
|
+
): number {
|
|
156
|
+
if (position === 'prepend') return 0;
|
|
157
|
+
if (typeof position === 'object') {
|
|
158
|
+
const idx = messages.findIndex((m) => m.id === position.afterId);
|
|
159
|
+
return idx === -1 ? messages.length : idx + 1;
|
|
160
|
+
}
|
|
161
|
+
return messages.length;
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
function updateLastStreaming(
|
|
123
165
|
messages: ChatMessage[],
|
|
124
166
|
patch: (m: ChatMessage) => ChatMessage,
|
|
@@ -171,6 +213,10 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
171
213
|
return { ...state, isLoading: true, error: null };
|
|
172
214
|
|
|
173
215
|
case 'HISTORY_LOAD_DONE':
|
|
216
|
+
// Stale-page guard: a read that raced a session switch commits nothing.
|
|
217
|
+
if (action.sessionId !== undefined && action.sessionId !== state.sessionId) {
|
|
218
|
+
return state;
|
|
219
|
+
}
|
|
174
220
|
return {
|
|
175
221
|
...state,
|
|
176
222
|
isLoading: false,
|
|
@@ -180,6 +226,20 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
180
226
|
oldestCursor: action.cursor,
|
|
181
227
|
};
|
|
182
228
|
|
|
229
|
+
case 'SESSION_SWITCH':
|
|
230
|
+
// Atomic transition to another session — the in-place alternative to the
|
|
231
|
+
// legacy `key={sessionId}` full remount. One dispatch swaps identity and
|
|
232
|
+
// transcript together, so the feed never renders a half-state (old
|
|
233
|
+
// messages under a new id, or a blank frame between "clear" and "set").
|
|
234
|
+
// Stream/tool state belongs to the OLD session and resets wholesale.
|
|
235
|
+
return {
|
|
236
|
+
...initialState,
|
|
237
|
+
sessionId: action.sessionId,
|
|
238
|
+
messages: action.messages ?? [],
|
|
239
|
+
historyLoaded: action.messages !== undefined,
|
|
240
|
+
isLoading: action.messages === undefined,
|
|
241
|
+
};
|
|
242
|
+
|
|
183
243
|
case 'HISTORY_MORE_START':
|
|
184
244
|
return { ...state, isLoadingMore: true };
|
|
185
245
|
|
|
@@ -411,10 +471,13 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
411
471
|
};
|
|
412
472
|
return { ...state, messages };
|
|
413
473
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
474
|
+
// ONE insert path for every position: resolve the index, splice.
|
|
475
|
+
const at = insertIndexFor(action.position, state.messages);
|
|
476
|
+
const next = [
|
|
477
|
+
...state.messages.slice(0, at),
|
|
478
|
+
action.message,
|
|
479
|
+
...state.messages.slice(at),
|
|
480
|
+
];
|
|
418
481
|
return { ...state, messages: next };
|
|
419
482
|
}
|
|
420
483
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// newSession() atomicity (chat-remount-flicker F2).
|
|
4
|
+
//
|
|
5
|
+
// The legacy sequence was MESSAGES_CLEAR → await createSession → SESSION_SET:
|
|
6
|
+
// the feed rendered EMPTY for the whole round-trip, and stayed empty forever
|
|
7
|
+
// when createSession failed. The contract now:
|
|
8
|
+
// 1. The previous conversation stays readable until the new session lands.
|
|
9
|
+
// 2. SESSION_SET swaps id + transcript in one commit.
|
|
10
|
+
// 3. On failure the old transcript survives alongside the error banner.
|
|
11
|
+
import { act, createElement } from 'react';
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
import { useChat, type UseChatReturn } from '../useChat';
|
|
18
|
+
import type { ChatMessage } from '../../types';
|
|
19
|
+
import type { ChatTransport } from '../../types/transport';
|
|
20
|
+
import type { HistoryPage, SessionInfo } from '../../types/session';
|
|
21
|
+
|
|
22
|
+
function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
|
|
23
|
+
return { id, role, content, createdAt: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeTransport(opts: {
|
|
27
|
+
createGate?: Promise<void>;
|
|
28
|
+
failCreate?: boolean;
|
|
29
|
+
}): ChatTransport {
|
|
30
|
+
return {
|
|
31
|
+
async createSession(): Promise<SessionInfo> {
|
|
32
|
+
if (opts.createGate) await opts.createGate;
|
|
33
|
+
if (opts.failCreate) throw new Error('relay unreachable');
|
|
34
|
+
return { sessionId: 'fresh-room', messages: [] };
|
|
35
|
+
},
|
|
36
|
+
async loadHistory(): Promise<HistoryPage> {
|
|
37
|
+
return {
|
|
38
|
+
messages: [msg('h1', 'old history')],
|
|
39
|
+
hasMore: false,
|
|
40
|
+
nextCursor: null,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
async *stream() {
|
|
44
|
+
/* unused */
|
|
45
|
+
},
|
|
46
|
+
async send(): Promise<ChatMessage> {
|
|
47
|
+
return msg('x', 'x', 'assistant');
|
|
48
|
+
},
|
|
49
|
+
async closeSession() {
|
|
50
|
+
/* unused */
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mountChat(roots: Root[], transport: ChatTransport): () => UseChatReturn {
|
|
56
|
+
let latest!: UseChatReturn;
|
|
57
|
+
const root = createRoot(document.createElement('div'));
|
|
58
|
+
roots.push(root);
|
|
59
|
+
act(() => {
|
|
60
|
+
root.render(
|
|
61
|
+
createElement(function W() {
|
|
62
|
+
latest = useChat({ transport, initialSessionId: 'room-old' });
|
|
63
|
+
return null;
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
return () => latest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('useChat — newSession atomicity', () => {
|
|
71
|
+
let roots: Root[] = [];
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
roots = [];
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
act(() => roots.forEach((r) => r.unmount()));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps the old transcript readable while createSession is in flight', async () => {
|
|
80
|
+
let release!: () => void;
|
|
81
|
+
const gate = new Promise<void>((r) => {
|
|
82
|
+
release = r;
|
|
83
|
+
});
|
|
84
|
+
const chat = mountChat(roots, makeTransport({ createGate: gate }));
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
});
|
|
88
|
+
expect(chat().messages.map((m) => m.content)).toEqual(['old history']);
|
|
89
|
+
|
|
90
|
+
let done!: Promise<void>;
|
|
91
|
+
act(() => {
|
|
92
|
+
done = chat().newSession();
|
|
93
|
+
});
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
});
|
|
97
|
+
// Round-trip in flight — the old conversation is STILL on screen.
|
|
98
|
+
expect(chat().messages.map((m) => m.content)).toEqual(['old history']);
|
|
99
|
+
|
|
100
|
+
await act(async () => {
|
|
101
|
+
release();
|
|
102
|
+
await done;
|
|
103
|
+
});
|
|
104
|
+
// Atomic swap: new id, new (empty) transcript, load settled.
|
|
105
|
+
expect(chat().sessionId).toBe('fresh-room');
|
|
106
|
+
expect(chat().messages).toEqual([]);
|
|
107
|
+
expect(chat().historyLoaded).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('a failed createSession keeps the old transcript and raises the banner', async () => {
|
|
111
|
+
const chat = mountChat(roots, makeTransport({ failCreate: true }));
|
|
112
|
+
await act(async () => {
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await chat().newSession();
|
|
118
|
+
});
|
|
119
|
+
expect(chat().messages.map((m) => m.content)).toEqual(['old history']);
|
|
120
|
+
expect(chat().sessionId).toBe('room-old');
|
|
121
|
+
expect(chat().error).toBe('relay unreachable');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// In-place session switch (chat-remount-flicker F2).
|
|
4
|
+
//
|
|
5
|
+
// Contract under test (see the switch effect in useChat):
|
|
6
|
+
// 1. Changing `initialSessionId` after mount dispatches one atomic
|
|
7
|
+
// SESSION_SWITCH — id + transcript swap together; with a warm
|
|
8
|
+
// `initialMessages` seed the feed never renders empty.
|
|
9
|
+
// 2. The background history read commits only for the session the user is
|
|
10
|
+
// still on — a slow page for an abandoned session is dropped
|
|
11
|
+
// (sessionId-guarded HISTORY_LOAD_DONE).
|
|
12
|
+
// 3. `sendMessage` racing a switch waits for the new session's page, so the
|
|
13
|
+
// optimistic bubble can't be replaced by the arriving history.
|
|
14
|
+
import { act, createElement, useState } from 'react';
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
|
|
20
|
+
import { useChat, type UseChatReturn } from '../useChat';
|
|
21
|
+
import type { ChatMessage } from '../../types';
|
|
22
|
+
import type { ChatTransport } from '../../types/transport';
|
|
23
|
+
import type { HistoryPage, SessionInfo } from '../../types/session';
|
|
24
|
+
|
|
25
|
+
function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
|
|
26
|
+
return { id, role, content, createdAt: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Transport whose per-session history resolves when the test releases it. */
|
|
30
|
+
function gatedTransport(pages: Record<string, HistoryPage>): {
|
|
31
|
+
transport: ChatTransport;
|
|
32
|
+
release: (sessionId: string) => void;
|
|
33
|
+
} {
|
|
34
|
+
const gates = new Map<string, { promise: Promise<void>; release: () => void }>();
|
|
35
|
+
const gateFor = (sessionId: string) => {
|
|
36
|
+
let g = gates.get(sessionId);
|
|
37
|
+
if (!g) {
|
|
38
|
+
let release!: () => void;
|
|
39
|
+
const promise = new Promise<void>((r) => {
|
|
40
|
+
release = r;
|
|
41
|
+
});
|
|
42
|
+
g = { promise, release };
|
|
43
|
+
gates.set(sessionId, g);
|
|
44
|
+
}
|
|
45
|
+
return g;
|
|
46
|
+
};
|
|
47
|
+
const transport: ChatTransport = {
|
|
48
|
+
async createSession(): Promise<SessionInfo> {
|
|
49
|
+
return { sessionId: 'created' };
|
|
50
|
+
},
|
|
51
|
+
async loadHistory(sessionId: string): Promise<HistoryPage> {
|
|
52
|
+
await gateFor(sessionId).promise;
|
|
53
|
+
return pages[sessionId] ?? { messages: [], hasMore: false, nextCursor: null };
|
|
54
|
+
},
|
|
55
|
+
async *stream() {
|
|
56
|
+
/* replies are irrelevant here */
|
|
57
|
+
},
|
|
58
|
+
async send(): Promise<ChatMessage> {
|
|
59
|
+
return msg('x', 'x', 'assistant');
|
|
60
|
+
},
|
|
61
|
+
async closeSession() {
|
|
62
|
+
/* unused */
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return { transport, release: (id) => gateFor(id).release() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Harness {
|
|
69
|
+
chat: () => UseChatReturn;
|
|
70
|
+
setSession: (id: string, seed?: readonly ChatMessage[]) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mount(
|
|
74
|
+
roots: Root[],
|
|
75
|
+
transport: ChatTransport,
|
|
76
|
+
initial: { id: string; seed?: readonly ChatMessage[] },
|
|
77
|
+
): Harness {
|
|
78
|
+
let latest!: UseChatReturn;
|
|
79
|
+
let set!: (v: { id: string; seed?: readonly ChatMessage[] }) => void;
|
|
80
|
+
const root = createRoot(document.createElement('div'));
|
|
81
|
+
roots.push(root);
|
|
82
|
+
act(() => {
|
|
83
|
+
root.render(
|
|
84
|
+
createElement(function W() {
|
|
85
|
+
const [session, setSession] = useState(initial);
|
|
86
|
+
set = setSession;
|
|
87
|
+
latest = useChat({
|
|
88
|
+
transport,
|
|
89
|
+
initialSessionId: session.id,
|
|
90
|
+
initialMessages: session.seed,
|
|
91
|
+
});
|
|
92
|
+
return null;
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
chat: () => latest,
|
|
98
|
+
setSession: (id, seed) => act(() => set({ id, seed })),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('useChat — in-place session switch', () => {
|
|
103
|
+
let roots: Root[] = [];
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
roots = [];
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
act(() => roots.forEach((r) => r.unmount()));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('switches atomically with a warm seed — never renders an empty frame', async () => {
|
|
112
|
+
const { transport, release } = gatedTransport({
|
|
113
|
+
'room-a': { messages: [msg('a1', 'A history')], hasMore: false, nextCursor: null },
|
|
114
|
+
'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
|
|
115
|
+
});
|
|
116
|
+
const h = mount(roots, transport, {
|
|
117
|
+
id: 'room-a',
|
|
118
|
+
seed: [msg('a1', 'A history')],
|
|
119
|
+
});
|
|
120
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['A history']);
|
|
121
|
+
|
|
122
|
+
h.setSession('room-b', [msg('b1', 'B cached')]);
|
|
123
|
+
// Seeded swap: new id + cached transcript in the same commit.
|
|
124
|
+
expect(h.chat().sessionId).toBe('room-b');
|
|
125
|
+
expect(h.chat().historyLoaded).toBe(true);
|
|
126
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['B cached']);
|
|
127
|
+
|
|
128
|
+
// Server page replaces the seed once it lands.
|
|
129
|
+
await act(async () => {
|
|
130
|
+
release('room-b');
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
});
|
|
133
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['B history']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('switches without a seed into the loading state, then commits the page', async () => {
|
|
137
|
+
const { transport, release } = gatedTransport({
|
|
138
|
+
'room-a': { messages: [], hasMore: false, nextCursor: null },
|
|
139
|
+
'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
|
|
140
|
+
});
|
|
141
|
+
const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });
|
|
142
|
+
|
|
143
|
+
h.setSession('room-b');
|
|
144
|
+
expect(h.chat().sessionId).toBe('room-b');
|
|
145
|
+
expect(h.chat().historyLoaded).toBe(false);
|
|
146
|
+
expect(h.chat().isLoading).toBe(true);
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
release('room-b');
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
});
|
|
152
|
+
expect(h.chat().historyLoaded).toBe(true);
|
|
153
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['B history']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('drops a slow page for a session the user already left (A→B→A)', async () => {
|
|
157
|
+
const { transport, release } = gatedTransport({
|
|
158
|
+
'room-a': { messages: [msg('a1', 'A history')], hasMore: false, nextCursor: null },
|
|
159
|
+
'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
|
|
160
|
+
});
|
|
161
|
+
const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });
|
|
162
|
+
|
|
163
|
+
h.setSession('room-b'); // B's read is gated — still in flight
|
|
164
|
+
h.setSession('room-a', [msg('a1', 'A cached')]); // back before B landed
|
|
165
|
+
expect(h.chat().sessionId).toBe('room-a');
|
|
166
|
+
|
|
167
|
+
// B's page finally arrives — it must NOT clobber room-a's transcript.
|
|
168
|
+
await act(async () => {
|
|
169
|
+
release('room-b');
|
|
170
|
+
await Promise.resolve();
|
|
171
|
+
});
|
|
172
|
+
expect(h.chat().sessionId).toBe('room-a');
|
|
173
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['A cached']);
|
|
174
|
+
|
|
175
|
+
await act(async () => {
|
|
176
|
+
release('room-a');
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
});
|
|
179
|
+
expect(h.chat().messages.map((m) => m.content)).toEqual(['A history']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('switching mid-stream aborts the old turn and leaves the new room clean', async () => {
|
|
183
|
+
// A stream that emits one chunk, then hangs until aborted.
|
|
184
|
+
let started!: () => void;
|
|
185
|
+
const streamStarted = new Promise<void>((r) => {
|
|
186
|
+
started = r;
|
|
187
|
+
});
|
|
188
|
+
const transport: ChatTransport = {
|
|
189
|
+
async createSession(): Promise<SessionInfo> {
|
|
190
|
+
return { sessionId: 'created' };
|
|
191
|
+
},
|
|
192
|
+
async loadHistory(sessionId: string): Promise<HistoryPage> {
|
|
193
|
+
return sessionId === 'room-b'
|
|
194
|
+
? { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null }
|
|
195
|
+
: { messages: [], hasMore: false, nextCursor: null };
|
|
196
|
+
},
|
|
197
|
+
async *stream(_s: string, _c: string, opts: { signal?: AbortSignal }) {
|
|
198
|
+
yield { type: 'chunk' as const, delta: 'partial…' };
|
|
199
|
+
started();
|
|
200
|
+
// Hang until the switch aborts us.
|
|
201
|
+
await new Promise<void>((resolve) => {
|
|
202
|
+
opts.signal?.addEventListener('abort', () => resolve(), { once: true });
|
|
203
|
+
});
|
|
204
|
+
if (opts.signal?.aborted) return;
|
|
205
|
+
},
|
|
206
|
+
async send(): Promise<ChatMessage> {
|
|
207
|
+
return msg('x', 'x', 'assistant');
|
|
208
|
+
},
|
|
209
|
+
async closeSession() {
|
|
210
|
+
/* unused */
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });
|
|
215
|
+
await act(async () => {
|
|
216
|
+
await Promise.resolve();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let send!: Promise<void>;
|
|
220
|
+
act(() => {
|
|
221
|
+
send = h.chat().sendMessage('trigger');
|
|
222
|
+
});
|
|
223
|
+
await act(async () => {
|
|
224
|
+
await streamStarted;
|
|
225
|
+
});
|
|
226
|
+
expect(h.chat().isStreaming).toBe(true);
|
|
227
|
+
|
|
228
|
+
// Switch away while the assistant is mid-stream.
|
|
229
|
+
h.setSession('room-b', [msg('b1', 'B cached')]);
|
|
230
|
+
await act(async () => {
|
|
231
|
+
await send;
|
|
232
|
+
await Promise.resolve();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const c = h.chat();
|
|
236
|
+
expect(c.sessionId).toBe('room-b');
|
|
237
|
+
expect(c.isStreaming).toBe(false);
|
|
238
|
+
// Nothing from room-a's aborted turn bleeds into room-b's feed.
|
|
239
|
+
expect(c.messages.some((m) => m.content.includes('partial'))).toBe(false);
|
|
240
|
+
expect(c.messages.some((m) => m.content === 'trigger')).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('a send racing the switch waits for the page — the optimistic bubble survives', async () => {
|
|
244
|
+
const { transport, release } = gatedTransport({
|
|
245
|
+
'room-a': { messages: [], hasMore: false, nextCursor: null },
|
|
246
|
+
'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
|
|
247
|
+
});
|
|
248
|
+
const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });
|
|
249
|
+
|
|
250
|
+
h.setSession('room-b');
|
|
251
|
+
// Send while the switch's history read is still gated.
|
|
252
|
+
let sendDone = false;
|
|
253
|
+
let send!: Promise<void>;
|
|
254
|
+
act(() => {
|
|
255
|
+
send = h.chat().sendMessage('hi from the race').then(() => {
|
|
256
|
+
sendDone = true;
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
await act(async () => {
|
|
260
|
+
await Promise.resolve();
|
|
261
|
+
});
|
|
262
|
+
// Blocked on the switch — no optimistic bubble yet, nothing to clobber.
|
|
263
|
+
expect(sendDone).toBe(false);
|
|
264
|
+
expect(h.chat().messages.some((m) => m.content === 'hi from the race')).toBe(false);
|
|
265
|
+
|
|
266
|
+
await act(async () => {
|
|
267
|
+
release('room-b');
|
|
268
|
+
await send;
|
|
269
|
+
});
|
|
270
|
+
const contents = h.chat().messages.map((m) => m.content);
|
|
271
|
+
// History first, then the user's message — nothing lost.
|
|
272
|
+
expect(contents[0]).toBe('B history');
|
|
273
|
+
expect(contents).toContain('hi from the race');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
initialState,
|
|
17
17
|
reducer,
|
|
18
18
|
type ChatAction,
|
|
19
|
+
type MessageInjectPosition,
|
|
19
20
|
} from '../core/reducer';
|
|
20
21
|
import { createId } from '../core/ids';
|
|
21
22
|
import { getChatLogger } from '../core/logger';
|
|
@@ -95,9 +96,11 @@ export interface UseChatReturn extends ChatState {
|
|
|
95
96
|
/**
|
|
96
97
|
* Inject a complete message from outside (push notification, admin
|
|
97
98
|
* takeover, system notice). De-duped by id. Position defaults to
|
|
98
|
-
* `append
|
|
99
|
+
* `append`; pass `prepend` for retroactive inserts or `{ afterId }` to
|
|
100
|
+
* splice into transcript order (e.g. a history reconcile placing server
|
|
101
|
+
* rows before the client's own newer live turn).
|
|
99
102
|
*/
|
|
100
|
-
injectMessage: (message: ChatMessage, position?:
|
|
103
|
+
injectMessage: (message: ChatMessage, position?: MessageInjectPosition) => void;
|
|
101
104
|
/**
|
|
102
105
|
* Patch fields of an existing message in place (e.g. live-edit the
|
|
103
106
|
* admin's last reply, mark a message as resolved). No-op if the id
|
|
@@ -118,7 +121,15 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
118
121
|
// bootstrap finished without producing one — e.g. autoCreateSession=false).
|
|
119
122
|
// Action methods (sendMessage, regenerate, …) await this so users who type
|
|
120
123
|
// before the first network round-trip resolves don't hit "No active session".
|
|
124
|
+
// An in-place session SWITCH re-points this at the switch promise so the
|
|
125
|
+
// same await gate covers both transitions.
|
|
121
126
|
const bootstrapRef = useRef<Promise<string | null> | null>(null);
|
|
127
|
+
// In-place session-switch machinery (see the switch effect below). `seq`
|
|
128
|
+
// invalidates a superseded switch; `switching` keeps `awaitSession` blocking
|
|
129
|
+
// until the new session's history page has committed.
|
|
130
|
+
const sessionPropRef = useRef(config.initialSessionId);
|
|
131
|
+
const switchSeqRef = useRef(0);
|
|
132
|
+
const switchingRef = useRef(false);
|
|
122
133
|
|
|
123
134
|
const { transport, autoCreateSession = true, streaming = true, pageSize = LIMITS.pageSize } =
|
|
124
135
|
config;
|
|
@@ -263,10 +274,78 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
263
274
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
264
275
|
}, []);
|
|
265
276
|
|
|
277
|
+
// Session SWITCH as a state transition (no remount needed). The mount
|
|
278
|
+
// bootstrap above reads `initialSessionId` once; historically a later change
|
|
279
|
+
// was ignored and hosts remounted the whole tree via `key={sessionId}` —
|
|
280
|
+
// a contract that turns ANY key-feeding state churn into a full feed wipe.
|
|
281
|
+
// A post-mount change of `initialSessionId` now switches IN PLACE: one
|
|
282
|
+
// atomic SESSION_SWITCH (seeded from the CURRENT `initialMessages` when the
|
|
283
|
+
// host holds a warm cache for the new room), then a background history read
|
|
284
|
+
// whose commit is sessionId-guarded in the reducer, so a read that raced a
|
|
285
|
+
// further switch clobbers nothing. The legacy remount pattern keeps working
|
|
286
|
+
// unchanged — a remounted engine never observes a prop change.
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const next = config.initialSessionId;
|
|
289
|
+
if (next === sessionPropRef.current) return;
|
|
290
|
+
sessionPropRef.current = next;
|
|
291
|
+
// A cleared prop hands session lifecycle back to the host (create path) —
|
|
292
|
+
// keep the current transcript rather than tearing it down.
|
|
293
|
+
if (!next || next === stateRef.current.sessionId) return;
|
|
294
|
+
|
|
295
|
+
const seq = ++switchSeqRef.current;
|
|
296
|
+
switchingRef.current = true;
|
|
297
|
+
abortRef.current?.abort();
|
|
298
|
+
const seed =
|
|
299
|
+
config.initialMessages !== undefined && config.initialMessages.length > 0
|
|
300
|
+
? [...config.initialMessages]
|
|
301
|
+
: undefined;
|
|
302
|
+
dispatch({ type: 'SESSION_SWITCH', sessionId: next, messages: seed });
|
|
303
|
+
log.lifecycle.info('sessionSwitch', { to: next, seeded: seed !== undefined });
|
|
304
|
+
|
|
305
|
+
const run = async (): Promise<string | null> => {
|
|
306
|
+
try {
|
|
307
|
+
const page = await transport.loadHistory(next, null, pageSize);
|
|
308
|
+
dispatch({
|
|
309
|
+
type: 'HISTORY_LOAD_DONE',
|
|
310
|
+
sessionId: next,
|
|
311
|
+
messages: page.messages,
|
|
312
|
+
hasMore: page.hasMore,
|
|
313
|
+
cursor: page.nextCursor,
|
|
314
|
+
});
|
|
315
|
+
log.lifecycle.success('sessionSwitch loaded', {
|
|
316
|
+
sessionId: next,
|
|
317
|
+
messages: page.messages.length,
|
|
318
|
+
});
|
|
319
|
+
return next;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
322
|
+
// A superseded switch reports nothing — the newer one owns the UI.
|
|
323
|
+
if (switchSeqRef.current === seq) {
|
|
324
|
+
lastErrorRef.current = e;
|
|
325
|
+
dispatch({ type: 'ERROR_SET', error: e.message });
|
|
326
|
+
config.onError?.(e);
|
|
327
|
+
log.error.error('sessionSwitch failed', { sessionId: next, message: e.message });
|
|
328
|
+
}
|
|
329
|
+
return next;
|
|
330
|
+
} finally {
|
|
331
|
+
if (switchSeqRef.current === seq) switchingRef.current = false;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
bootstrapRef.current = run();
|
|
335
|
+
// Only the id is the trigger; seed/transport/pageSize are read as-of the
|
|
336
|
+
// switch moment on purpose.
|
|
337
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
338
|
+
}, [config.initialSessionId]);
|
|
339
|
+
|
|
266
340
|
/** Wait for the initial session bootstrap to settle, then return whatever
|
|
267
|
-
* sessionId is now in state. Safe to call multiple times.
|
|
341
|
+
* sessionId is now in state. Safe to call multiple times. During an
|
|
342
|
+
* in-place session switch the id is already in state while the transcript
|
|
343
|
+
* is still loading — block until the page committed, so a racing send's
|
|
344
|
+
* optimistic bubble can't be replaced by the arriving history. */
|
|
268
345
|
const awaitSession = useCallback(async (): Promise<string | null> => {
|
|
269
|
-
if (
|
|
346
|
+
if (!switchingRef.current && stateRef.current.sessionId) {
|
|
347
|
+
return stateRef.current.sessionId;
|
|
348
|
+
}
|
|
270
349
|
if (bootstrapRef.current) {
|
|
271
350
|
const id = await bootstrapRef.current;
|
|
272
351
|
if (id) return id;
|
|
@@ -653,7 +732,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
653
732
|
}, []);
|
|
654
733
|
|
|
655
734
|
const injectMessage = useCallback(
|
|
656
|
-
(message: ChatMessage, position?:
|
|
735
|
+
(message: ChatMessage, position?: MessageInjectPosition) => {
|
|
657
736
|
dispatch({ type: 'MESSAGE_INJECT', message, position });
|
|
658
737
|
},
|
|
659
738
|
[],
|
|
@@ -707,7 +786,10 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
707
786
|
/* ignore */
|
|
708
787
|
}
|
|
709
788
|
}
|
|
710
|
-
|
|
789
|
+
// NO eager MESSAGES_CLEAR here: the old clear→await→set sequence blanked
|
|
790
|
+
// the feed for the whole createSession round-trip (and left it blank
|
|
791
|
+
// forever on failure). SESSION_SET below swaps id + transcript atomically;
|
|
792
|
+
// until it lands the previous conversation stays readable.
|
|
711
793
|
try {
|
|
712
794
|
const info = await transport.createSession({ metadata: config.metadata });
|
|
713
795
|
dispatch({
|