@djangocfg/ui-tools 2.1.459 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.459",
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.459",
338
- "@djangocfg/ui-core": "^2.1.459",
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.459",
422
- "@djangocfg/typescript-config": "^2.1.459",
423
- "@djangocfg/ui-core": "^2.1.459",
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, then return focus to the composer. Clicking the Send button
429
- // moves focus to that button; after the message goes the caret should
430
- // be back in the input so the user can keep typing without reaching for
431
- // the mouse (the same UX the stream-end refocus gives on Enter-send
432
- // this covers the button-click and instant/non-streaming paths too).
433
- // Resolve the live handle from the registry so it works for both the
434
- // built-in <textarea> and the custom TipTap composer. No-op while the
435
- // gate is closed (gatedComposer.submit swallows it).
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 { reducer, initialState, type ChatState, type ChatAction } from './reducer';
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?: 'append' | 'prepend' }
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
- const next =
415
- action.position === 'prepend'
416
- ? [action.message, ...state.messages]
417
- : [...state.messages, action.message];
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` pass `prepend` for retroactive inserts.
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?: 'append' | 'prepend') => void;
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 (stateRef.current.sessionId) return stateRef.current.sessionId;
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?: 'append' | 'prepend') => {
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
- dispatch({ type: 'MESSAGES_CLEAR' });
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({
@@ -85,6 +85,7 @@ export {
85
85
  deriveInitials,
86
86
  type ChatState,
87
87
  type ChatAction,
88
+ type MessageInjectPosition,
88
89
  type TokenBuffer,
89
90
  } from './core';
90
91