@djangocfg/ui-tools 2.1.449 → 2.1.452

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.449",
3
+ "version": "2.1.452",
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.449",
338
- "@djangocfg/ui-core": "^2.1.449",
337
+ "@djangocfg/i18n": "^2.1.452",
338
+ "@djangocfg/ui-core": "^2.1.452",
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.449",
422
- "@djangocfg/typescript-config": "^2.1.449",
423
- "@djangocfg/ui-core": "^2.1.449",
421
+ "@djangocfg/i18n": "^2.1.452",
422
+ "@djangocfg/typescript-config": "^2.1.452",
423
+ "@djangocfg/ui-core": "^2.1.452",
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",
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { ChatMessage } from '../../types';
4
+ import { initialState, reducer, type ChatAction, type ChatState } from '../reducer';
5
+
6
+ /**
7
+ * Regression: an errored turn's banner must PERSIST until a genuinely new
8
+ * answer turn begins. A prior "anti-jitter" fix (clear `error` on
9
+ * STREAM_CHUNK / STREAM_DONE / MESSAGE_USER_ADD) over-reached and wiped the
10
+ * CURRENT turn's error before the user could read it — most visibly the
11
+ * router-404 case, where STREAM_ERROR + a terminal message_end land on the
12
+ * SAME turn so the terminal erased its own banner.
13
+ */
14
+
15
+ const user = (id: string, content = 'hi'): ChatMessage => ({
16
+ id,
17
+ role: 'user',
18
+ content,
19
+ createdAt: 1,
20
+ });
21
+
22
+ /** Replay a sequence of actions from `initialState`. */
23
+ function run(actions: ChatAction[]): ChatState {
24
+ return actions.reduce(reducer, initialState);
25
+ }
26
+
27
+ describe('reducer — errored turn banner persistence', () => {
28
+ it('(a) STREAM_ERROR then STREAM_DONE for the SAME turn keeps the error (router-404)', () => {
29
+ const state = run([
30
+ { type: 'MESSAGE_USER_ADD', message: user('u1') },
31
+ { type: 'STREAM_START', id: 'a1' },
32
+ { type: 'STREAM_ERROR', id: 'a1', message: 'llm: router 404: 404 page not found' },
33
+ // The terminal message_end arrives for the very same turn.
34
+ { type: 'STREAM_DONE', id: 'a1' },
35
+ ]);
36
+
37
+ // The banner survives the turn's own terminal — this is the load-bearing
38
+ // assertion (what the user reads). The empty errored placeholder itself is
39
+ // dropped by the HITL empty-bubble logic, which is fine.
40
+ expect(state.error).toBe('llm: router 404: 404 page not found');
41
+ expect(state.errorTurnId).toBe('a1');
42
+ expect(state.isStreaming).toBe(false);
43
+ });
44
+
45
+ it('the errored turn keeps its error message when it produced content', () => {
46
+ // Same-turn STREAM_DONE must not wipe the banner even when the errored
47
+ // message survives the empty-bubble drop (it has content).
48
+ const state = run([
49
+ { type: 'STREAM_START', id: 'a1' },
50
+ { type: 'STREAM_CHUNK', delta: 'partial ' },
51
+ { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
52
+ { type: 'STREAM_DONE', id: 'a1' },
53
+ ]);
54
+ expect(state.error).toBe('boom');
55
+ expect(state.errorTurnId).toBe('a1');
56
+ const msg = state.messages.find((m) => m.id === 'a1');
57
+ expect(msg?.isError).toBe(true);
58
+ expect(msg?.content).toBe('partial ');
59
+ });
60
+
61
+ it('a trailing STREAM_CHUNK landing on nothing does not wipe the banner', () => {
62
+ const state = run([
63
+ { type: 'STREAM_START', id: 'a1' },
64
+ { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
65
+ // STREAM_ERROR cleared isStreaming, so this chunk finds no streaming
66
+ // message (idx === -1) and must not clear the error.
67
+ { type: 'STREAM_CHUNK', delta: 'x' },
68
+ ]);
69
+ expect(state.error).toBe('boom');
70
+ expect(state.errorTurnId).toBe('a1');
71
+ });
72
+
73
+ it('(b) STREAM_ERROR then MESSAGE_USER_ADD keeps the error while the user types the next message', () => {
74
+ const state = run([
75
+ { type: 'STREAM_START', id: 'a1' },
76
+ { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
77
+ { type: 'MESSAGE_USER_ADD', message: user('u2', 'try again') },
78
+ ]);
79
+
80
+ expect(state.error).toBe('boom');
81
+ expect(state.errorTurnId).toBe('a1');
82
+ // The new user message is appended.
83
+ expect(state.messages.some((m) => m.id === 'u2')).toBe(true);
84
+ });
85
+
86
+ it('(c) a genuinely NEW turn (STREAM_START + STREAM_CHUNK) clears the stale error', () => {
87
+ const errored = run([
88
+ { type: 'STREAM_START', id: 'a1' },
89
+ { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
90
+ { type: 'MESSAGE_USER_ADD', message: user('u2') },
91
+ ]);
92
+ expect(errored.error).toBe('boom');
93
+
94
+ // New turn opens with a fresh placeholder id.
95
+ const afterStart = reducer(errored, { type: 'STREAM_START', id: 'a2' });
96
+ expect(afterStart.error).toBeNull();
97
+ expect(afterStart.errorTurnId).toBeNull();
98
+
99
+ // And, independently, the first chunk of a new turn also clears it.
100
+ const viaChunk = reducer(
101
+ reducer(errored, { type: 'STREAM_START', id: 'a2' } as ChatAction),
102
+ { type: 'STREAM_CHUNK', delta: 'Hello' },
103
+ );
104
+ expect(viaChunk.error).toBeNull();
105
+ expect(viaChunk.errorTurnId).toBeNull();
106
+ expect(viaChunk.messages.find((m) => m.id === 'a2')?.content).toBe('Hello');
107
+ });
108
+
109
+ it('re-emitted STREAM_START for the errored turn itself does NOT clear its banner', () => {
110
+ // Not a strictly newer turn — same id.
111
+ const state = run([
112
+ { type: 'STREAM_START', id: 'a1' },
113
+ { type: 'STREAM_ERROR', id: 'a1', message: 'boom' },
114
+ ]);
115
+ const again = reducer(state, { type: 'STREAM_START', id: 'a1' });
116
+ // STREAM_START while not streaming re-adds a placeholder, but since the id
117
+ // matches errorTurnId the banner survives.
118
+ expect(again.error).toBe('boom');
119
+ expect(again.errorTurnId).toBe('a1');
120
+ });
121
+
122
+ it('(d) HITL empty-placeholder STREAM_DONE still drops the empty bubble', () => {
123
+ // First stream produced only tool calls + approval_required, no text body.
124
+ const state = run([
125
+ { type: 'STREAM_START', id: 'a1' },
126
+ { type: 'STREAM_DONE', id: 'a1' }, // empty content, no toolCalls
127
+ ]);
128
+ expect(state.messages.find((m) => m.id === 'a1')).toBeUndefined();
129
+ expect(state.isStreaming).toBe(false);
130
+ // No error was ever set.
131
+ expect(state.error).toBeNull();
132
+ });
133
+
134
+ it('STREAM_ERROR without an id falls back to the last streaming placeholder id', () => {
135
+ const state = run([
136
+ { type: 'STREAM_START', id: 'a1' },
137
+ { type: 'STREAM_ERROR', message: 'network dropped' },
138
+ ]);
139
+ expect(state.error).toBe('network dropped');
140
+ expect(state.errorTurnId).toBe('a1');
141
+ });
142
+ });
@@ -32,6 +32,15 @@ export interface ChatState {
32
32
  hasMore: boolean;
33
33
  oldestCursor: string | null;
34
34
  error: string | null;
35
+ /**
36
+ * The message id of the turn the current `error` belongs to (the assistant
37
+ * placeholder that errored), or null. Lets the reducer keep an errored turn's
38
+ * banner visible until a *strictly newer* turn begins — the errored turn's own
39
+ * terminal (STREAM_DONE), its trailing chunks, and the user typing the next
40
+ * message must NOT clear it; only a new turn's STREAM_START / first STREAM_CHunk
41
+ * (landing on a different id) does. Cleared alongside `error`.
42
+ */
43
+ errorTurnId: string | null;
35
44
  }
36
45
 
37
46
  export const initialState: ChatState = {
@@ -44,6 +53,7 @@ export const initialState: ChatState = {
44
53
  hasMore: true,
45
54
  oldestCursor: null,
46
55
  error: null,
56
+ errorTurnId: null,
47
57
  };
48
58
 
49
59
  export type ChatAction =
@@ -154,6 +164,7 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
154
164
  // loadHistory → HISTORY_LOAD_DONE) leaves `historyLoaded` untouched.
155
165
  historyLoaded: action.messages !== undefined ? true : state.historyLoaded,
156
166
  error: null,
167
+ errorTurnId: null,
157
168
  };
158
169
 
159
170
  case 'HISTORY_LOAD_START':
@@ -183,10 +194,13 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
183
194
  };
184
195
 
185
196
  case 'MESSAGE_USER_ADD':
197
+ // Do NOT clear `error` here: the prior turn's banner must stay readable
198
+ // while the user types + sends the next message. It is retracted only
199
+ // once the next answer actually begins (STREAM_START / first STREAM_CHUNK
200
+ // of the new turn), keyed off `errorTurnId`.
186
201
  return {
187
202
  ...state,
188
203
  messages: [...state.messages, action.message],
189
- error: null,
190
204
  };
191
205
 
192
206
  case 'STREAM_START': {
@@ -204,23 +218,37 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
204
218
  createdAt: action.createdAt ?? Date.now(),
205
219
  isStreaming: true,
206
220
  };
221
+ // A genuinely new answer turn is opening. If a stale error from a prior
222
+ // turn is still showing, retract it now so the banner can't sit above the
223
+ // new live answer. Only clear when this is a STRICTLY newer turn (a
224
+ // different id than the one that errored) — a re-emitted STREAM_START for
225
+ // the errored turn itself must not wipe its own still-unread banner.
226
+ const clearStaleError = state.error && state.errorTurnId !== action.id;
207
227
  return {
208
228
  ...state,
209
229
  isStreaming: true,
210
230
  messages: [...state.messages, placeholder],
231
+ ...(clearStaleError ? { error: null, errorTurnId: null } : {}),
211
232
  };
212
233
  }
213
234
 
214
235
  case 'STREAM_CHUNK': {
236
+ const idx = findLastStreamingIndex(state.messages);
215
237
  const messages = updateLastStreaming(state.messages, (m) => ({
216
238
  ...m,
217
239
  content: m.content + action.delta,
218
240
  }));
219
- // Content is now streaming in for the current turn (STREAM_START opened a
220
- // fresh placeholder; chunks only land on it). If an error from a prior
221
- // turn is still showing, retract it the moment a good answer begins so
222
- // the banner can't sit above live content. Only touch `error` when set.
223
- return state.error ? { ...state, error: null, messages } : { ...state, messages };
241
+ // Content is streaming in. Retract a stale error only when this chunk
242
+ // lands on a REAL assistant message whose id differs from `errorTurnId`
243
+ // i.e. a strictly newer turn. A trailing chunk of the errored turn
244
+ // itself (same id), or a chunk that lands on nothing (idx === -1), must
245
+ // NOT wipe the still-unread banner.
246
+ const landingId = idx === -1 ? null : state.messages[idx].id;
247
+ const clearStaleError =
248
+ state.error !== null && landingId !== null && landingId !== state.errorTurnId;
249
+ return clearStaleError
250
+ ? { ...state, error: null, errorTurnId: null, messages }
251
+ : { ...state, messages };
224
252
  }
225
253
 
226
254
  case 'STREAM_TOOL_ACTIVITY': {
@@ -293,11 +321,13 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
293
321
  msg && !msg.content && !(msg.toolCalls?.length)
294
322
  ? patched.filter((m) => m.id !== action.id)
295
323
  : patched;
296
- // A turn that reaches message_end completed successfully (error is the
297
- // other terminal event the two are mutually exclusive per turn). Any
298
- // `error` still in state therefore belongs to a prior turn and is now
299
- // stale, so clear it: otherwise the banner lingers above a good answer.
300
- return { ...state, isStreaming: false, error: null, messages };
324
+ // Do NOT clear `error` here. STREAM_ERROR + a terminal message_end can
325
+ // arrive for the SAME turn (e.g. router 404), so wiping `error` on the
326
+ // terminal would erase the current turn's own banner before the user can
327
+ // read it. A stale error from a prior turn is instead retracted when the
328
+ // NEXT turn begins (STREAM_START / first STREAM_CHUNK), keyed off
329
+ // `errorTurnId`.
330
+ return { ...state, isStreaming: false, messages };
301
331
  }
302
332
 
303
333
  case 'STREAM_CANCELLED': {
@@ -318,7 +348,15 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
318
348
  isError: true,
319
349
  }))
320
350
  : state.messages;
321
- return { ...state, isStreaming: false, error: action.message, messages };
351
+ // Tag the error with the turn it belongs to so its banner survives this
352
+ // turn's own terminal / trailing chunks / the next user message, and is
353
+ // retracted only when a strictly newer turn begins. When the error has no
354
+ // id, fall back to the last streaming placeholder's id (that is the turn
355
+ // that just failed) so STREAM_CHUNK / STREAM_START can still key off it.
356
+ const erroredIdx = findLastStreamingIndex(state.messages);
357
+ const errorTurnId =
358
+ action.id ?? (erroredIdx === -1 ? null : state.messages[erroredIdx].id);
359
+ return { ...state, isStreaming: false, error: action.message, errorTurnId, messages };
322
360
  }
323
361
 
324
362
  case 'STREAM_CANCEL_PLACEHOLDER':
@@ -395,13 +433,23 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
395
433
  messages: [],
396
434
  isStreaming: false,
397
435
  error: null,
436
+ errorTurnId: null,
398
437
  };
399
438
 
400
439
  case 'ERROR_SET':
401
440
  // An error settles the initial load — a failed bootstrap must not leave
402
441
  // a stuck spinner; clear `isLoading` and mark the load resolved so the
403
442
  // host can fall through to its error/empty surface.
404
- return { ...state, error: action.error, isLoading: false, historyLoaded: true };
443
+ // Bootstrap/host-level error not tied to a stream turn, so it has no
444
+ // turn id. Reset any stale tag so a later STREAM_START/CHUNK won't fail to
445
+ // clear it (or clear it prematurely).
446
+ return {
447
+ ...state,
448
+ error: action.error,
449
+ errorTurnId: null,
450
+ isLoading: false,
451
+ historyLoaded: true,
452
+ };
405
453
 
406
454
  case 'ATTACHMENT_PROGRESS': {
407
455
  const messages = patchMessageById(state.messages, action.messageId, (m) => {