@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.
|
|
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.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
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
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
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
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// stale
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|