@djangocfg/ui-tools 2.1.376 → 2.1.378
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/dist/ChatRoot-EJC5Y2YM.cjs +14 -0
- package/dist/{ChatRoot-F5XXERXU.cjs.map → ChatRoot-EJC5Y2YM.cjs.map} +1 -1
- package/dist/ChatRoot-QOSKJPM6.mjs +5 -0
- package/dist/{ChatRoot-T7D7QRCH.mjs.map → ChatRoot-QOSKJPM6.mjs.map} +1 -1
- package/dist/{chunk-JXBEKSNT.mjs → chunk-QLMKCSR6.mjs} +50 -26
- package/dist/chunk-QLMKCSR6.mjs.map +1 -0
- package/dist/{chunk-UVIFD3TH.cjs → chunk-SI5RD2GD.cjs} +51 -25
- package/dist/chunk-SI5RD2GD.cjs.map +1 -0
- package/dist/index.cjs +102 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -3
- package/dist/index.d.ts +103 -3
- package/dist/index.mjs +48 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/Chat/Chat.story.tsx +101 -0
- package/src/tools/Chat/README.md +116 -1
- package/src/tools/Chat/components/ToolCalls.tsx +39 -12
- package/src/tools/Chat/hooks/useChatComposer.ts +41 -9
- package/src/tools/Chat/index.ts +5 -0
- package/src/tools/Chat/utils/sanitizeDraft.ts +72 -0
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +40 -0
- package/src/tools/MarkdownEditor/README.md +34 -0
- package/src/tools/MarkdownEditor/submitOnEnter.ts +67 -0
- package/dist/ChatRoot-F5XXERXU.cjs +0 -14
- package/dist/ChatRoot-T7D7QRCH.mjs +0 -5
- package/dist/chunk-JXBEKSNT.mjs.map +0 -1
- package/dist/chunk-UVIFD3TH.cjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.378",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -156,8 +156,8 @@
|
|
|
156
156
|
"check": "tsc --noEmit"
|
|
157
157
|
},
|
|
158
158
|
"peerDependencies": {
|
|
159
|
-
"@djangocfg/i18n": "^2.1.
|
|
160
|
-
"@djangocfg/ui-core": "^2.1.
|
|
159
|
+
"@djangocfg/i18n": "^2.1.378",
|
|
160
|
+
"@djangocfg/ui-core": "^2.1.378",
|
|
161
161
|
"consola": "^3.4.2",
|
|
162
162
|
"lodash-es": "^4.18.1",
|
|
163
163
|
"lucide-react": "^0.545.0",
|
|
@@ -211,10 +211,10 @@
|
|
|
211
211
|
"material-file-icons": "^2.4.0"
|
|
212
212
|
},
|
|
213
213
|
"devDependencies": {
|
|
214
|
-
"@djangocfg/i18n": "^2.1.
|
|
214
|
+
"@djangocfg/i18n": "^2.1.378",
|
|
215
215
|
"@djangocfg/playground": "workspace:*",
|
|
216
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
217
|
-
"@djangocfg/ui-core": "^2.1.
|
|
216
|
+
"@djangocfg/typescript-config": "^2.1.378",
|
|
217
|
+
"@djangocfg/ui-core": "^2.1.378",
|
|
218
218
|
"@types/lodash-es": "^4.17.12",
|
|
219
219
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
220
220
|
"@types/node": "^24.7.2",
|
|
@@ -1283,6 +1283,107 @@ export const WailsLikeVirtualization = () => {
|
|
|
1283
1283
|
);
|
|
1284
1284
|
};
|
|
1285
1285
|
|
|
1286
|
+
// ---------------------------------------------------------------------------
|
|
1287
|
+
// 16) WithRenderAfterCalls — rich UI outside tool panels
|
|
1288
|
+
// Demonstrates: renderAfterCalls, hideToolCalls, renderToolCall
|
|
1289
|
+
// ---------------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
const SEARCH_SEQUENCE: ChatStreamEvent[] = [
|
|
1292
|
+
{ type: 'chunk', delta: 'Searching the catalog for you.\n\n' },
|
|
1293
|
+
{
|
|
1294
|
+
type: 'tool_call_start',
|
|
1295
|
+
toolId: 'v1',
|
|
1296
|
+
name: 'search_vehicles',
|
|
1297
|
+
input: { make: 'BMW', year_min: 2022 },
|
|
1298
|
+
},
|
|
1299
|
+
{ type: 'tool_call_delta', toolId: 'v1', delta: 'querying…\n' },
|
|
1300
|
+
{
|
|
1301
|
+
type: 'tool_call_end',
|
|
1302
|
+
toolId: 'v1',
|
|
1303
|
+
output: {
|
|
1304
|
+
items: [
|
|
1305
|
+
{ id: 'car-001', make: 'BMW', model: '5 Series', year: 2023, price: 45000 },
|
|
1306
|
+
{ id: 'car-002', make: 'BMW', model: 'X5', year: 2022, price: 72000 },
|
|
1307
|
+
],
|
|
1308
|
+
total: 2,
|
|
1309
|
+
},
|
|
1310
|
+
status: 'success',
|
|
1311
|
+
},
|
|
1312
|
+
{ type: 'chunk', delta: 'Found 2 vehicles — see the cards below.' },
|
|
1313
|
+
{ type: 'message_end' },
|
|
1314
|
+
];
|
|
1315
|
+
|
|
1316
|
+
function MockVehicleCards({ calls }: { calls: import('./types').ChatToolCall[] }) {
|
|
1317
|
+
const items: Array<{ id: string; make: string; model: string; year: number; price: number }> = [];
|
|
1318
|
+
for (const call of calls) {
|
|
1319
|
+
if (call.status !== 'success' || call.name !== 'search_vehicles') continue;
|
|
1320
|
+
const out = call.output as { items?: typeof items } | null;
|
|
1321
|
+
for (const item of out?.items ?? []) items.push(item);
|
|
1322
|
+
}
|
|
1323
|
+
if (items.length === 0) return null;
|
|
1324
|
+
return (
|
|
1325
|
+
<div className="mt-2 space-y-1.5">
|
|
1326
|
+
{items.map((v) => (
|
|
1327
|
+
<div
|
|
1328
|
+
key={v.id}
|
|
1329
|
+
className="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs"
|
|
1330
|
+
>
|
|
1331
|
+
<div className="size-10 shrink-0 rounded bg-muted" />
|
|
1332
|
+
<div className="min-w-0 flex-1">
|
|
1333
|
+
<p className="font-semibold">{v.make} {v.model}</p>
|
|
1334
|
+
<p className="text-muted-foreground">{v.year}</p>
|
|
1335
|
+
</div>
|
|
1336
|
+
<span className="font-mono text-sm font-semibold">${v.price.toLocaleString()}</span>
|
|
1337
|
+
</div>
|
|
1338
|
+
))}
|
|
1339
|
+
</div>
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
export const WithRenderAfterCalls = () => {
|
|
1344
|
+
const [hide] = useBoolean('hide-panels', { defaultValue: false, label: 'hideToolCalls (hide accordion panels)' });
|
|
1345
|
+
const [useCustom] = useBoolean('custom-call', { defaultValue: false, label: 'renderToolCall (custom per-call renderer)' });
|
|
1346
|
+
|
|
1347
|
+
const transport = useMemo(
|
|
1348
|
+
() => createMockTransport({ replies: [SEARCH_SEQUENCE, SEARCH_SEQUENCE], latencyMs: 40 }),
|
|
1349
|
+
[],
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
const toolCallsProps = useMemo(
|
|
1353
|
+
() => ({
|
|
1354
|
+
hideToolCalls: hide,
|
|
1355
|
+
renderAfterCalls: (calls: import('./types').ChatToolCall[]) => <MockVehicleCards calls={calls} />,
|
|
1356
|
+
...(useCustom
|
|
1357
|
+
? {
|
|
1358
|
+
renderToolCall: (call: import('./types').ChatToolCall) => (
|
|
1359
|
+
<div className="flex items-center gap-2 rounded border border-border bg-muted/40 px-2 py-1 text-xs">
|
|
1360
|
+
<span className="font-mono text-muted-foreground">{call.name}</span>
|
|
1361
|
+
<span className={`ml-auto rounded px-1 py-0.5 text-[10px] font-medium ${
|
|
1362
|
+
call.status === 'success' ? 'bg-emerald-500/10 text-emerald-600' : 'bg-amber-500/10 text-amber-600'
|
|
1363
|
+
}`}>{call.status}</span>
|
|
1364
|
+
</div>
|
|
1365
|
+
),
|
|
1366
|
+
}
|
|
1367
|
+
: {}),
|
|
1368
|
+
}),
|
|
1369
|
+
[hide, useCustom],
|
|
1370
|
+
);
|
|
1371
|
+
|
|
1372
|
+
return (
|
|
1373
|
+
<Frame h={560}>
|
|
1374
|
+
<ChatRoot
|
|
1375
|
+
transport={transport}
|
|
1376
|
+
config={{
|
|
1377
|
+
greeting: 'Vehicle search demo',
|
|
1378
|
+
placeholder: 'Ask "find BMW vehicles"…',
|
|
1379
|
+
suggestions: [{ label: 'Find BMW vehicles', prompt: 'Find BMW vehicles from 2022' }],
|
|
1380
|
+
}}
|
|
1381
|
+
toolCallsProps={toolCallsProps}
|
|
1382
|
+
/>
|
|
1383
|
+
</Frame>
|
|
1384
|
+
);
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1286
1387
|
// ---------------------------------------------------------------------------
|
|
1287
1388
|
// AutoFocusOnStreamEnd — refocus composer the moment a reply lands
|
|
1288
1389
|
// ---------------------------------------------------------------------------
|
package/src/tools/Chat/README.md
CHANGED
|
@@ -168,6 +168,68 @@ import { LazyJsonTree } from '@djangocfg/ui-tools/json-tree';
|
|
|
168
168
|
|
|
169
169
|
We don't import `LazyJsonTree` automatically — keeping the chat dep-light. The host opts in.
|
|
170
170
|
|
|
171
|
+
### ToolCalls — rich UI after panels (`renderAfterCalls`)
|
|
172
|
+
|
|
173
|
+
`renderAfterCalls` renders **outside and below** all collapsible panels — always visible, regardless of whether panels are expanded or hidden. It receives the full `calls` array so you can aggregate across multiple tool calls (e.g. collect all vehicle IDs from `search_vehicles` + `get_vehicle` and render cards).
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
<ChatRoot
|
|
177
|
+
toolCallsProps={{
|
|
178
|
+
// Rich UI below all panels — always visible, never inside collapsed accordions
|
|
179
|
+
renderAfterCalls: (calls) => <VehicleCardList calls={calls} />,
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
#### hideToolCalls — show only rich UI, no raw panels
|
|
185
|
+
|
|
186
|
+
Set `hideToolCalls={true}` to suppress accordion panels entirely while `renderAfterCalls` still renders. Use when you want clean product UI without raw tool payloads visible.
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
<ChatRoot
|
|
190
|
+
toolCallsProps={{
|
|
191
|
+
hideToolCalls: true, // hides accordion panels
|
|
192
|
+
renderAfterCalls: (calls) => <VehicleCards calls={calls} />, // still runs
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`hideToolCalls` only affects the accordion panels — `renderAfterCalls` is always independent.
|
|
198
|
+
|
|
199
|
+
#### renderToolCall — custom per-call panel renderer
|
|
200
|
+
|
|
201
|
+
Replace the default `<ToolCallItem>` accordion with your own UI. Return `null` to suppress a specific call.
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
<ChatRoot
|
|
205
|
+
toolCallsProps={{
|
|
206
|
+
renderToolCall: (call) => (
|
|
207
|
+
<div className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
|
208
|
+
<span className="font-mono">{call.name}</span>
|
|
209
|
+
<span className="ml-auto">{call.status}</span>
|
|
210
|
+
</div>
|
|
211
|
+
),
|
|
212
|
+
}}
|
|
213
|
+
/>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### Full example — vehicle cards from tool output
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
import { buildVamcarToolCallsProps } from './chat/toolPayloads';
|
|
220
|
+
|
|
221
|
+
// toolPayloads.tsx:
|
|
222
|
+
export function buildVamcarToolCallsProps(): Omit<ToolCallsProps, 'calls'> {
|
|
223
|
+
return {
|
|
224
|
+
renderAfterCalls: (calls) => <VehicleCardsFromCalls calls={calls} />,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// In your chat component:
|
|
229
|
+
const toolCallsProps = useMemo(() => buildVamcarToolCallsProps(), []);
|
|
230
|
+
<ChatRoot transport={transport} toolCallsProps={toolCallsProps} />
|
|
231
|
+
```
|
|
232
|
+
|
|
171
233
|
## Personas (user & assistant identity)
|
|
172
234
|
|
|
173
235
|
Pass identities once on `<ChatRoot config>` — every bubble gets the right name, avatar and `aria-label`. Outgoing user messages get the `sender` field auto-stamped from `config.user`.
|
|
@@ -342,6 +404,48 @@ Options:
|
|
|
342
404
|
The hook only fires on the `true → false` edge — flipping `enabled`
|
|
343
405
|
mid-stream won't steal focus.
|
|
344
406
|
|
|
407
|
+
## Draft sanitation (pre-submit)
|
|
408
|
+
|
|
409
|
+
`useChatComposer.submit()` cleans the draft before handing it to your
|
|
410
|
+
`onSubmit` — mirroring what ChatGPT / Claude / Telegram ship. The
|
|
411
|
+
helper is also exported standalone for custom composers:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { sanitizeDraft, isSubmittableDraft } from '@djangocfg/ui-tools'
|
|
415
|
+
|
|
416
|
+
sanitizeDraft(' \n\nhello world\n ') // → 'hello world'
|
|
417
|
+
isSubmittableDraft(' \n ') // → false
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Rules (intentionally minimal)
|
|
421
|
+
|
|
422
|
+
| Rule | Action | Why |
|
|
423
|
+
|---|---|---|
|
|
424
|
+
| Trim outer whitespace | ✅ | Stray newlines/spaces at the edges are never intentional. |
|
|
425
|
+
| Normalise `\r\n` / `\r` → `\n` | ✅ | Deterministic shape for LLM tokeniser + markdown render. |
|
|
426
|
+
| Strip ZWSP / ZWNJ / ZWJ / BOM | ✅ | Pasted from rich web pages, invisible, break tokenisation. |
|
|
427
|
+
| **Collapse internal whitespace runs** | ❌ | Would mangle code indentation. |
|
|
428
|
+
| **Cap consecutive blank lines** | ❌ | Could be intentional separator (markdown / structured prompt). |
|
|
429
|
+
| **Strip bidi overrides** (LRM/RLM/U+202A..E) | ❌ | Legitimately used in Arabic/Hebrew RTL content. |
|
|
430
|
+
| Touch tabs vs spaces, emoji, mentions, URLs | ❌ | Passthrough. |
|
|
431
|
+
|
|
432
|
+
Idempotent: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
|
|
433
|
+
|
|
434
|
+
### Escape hatch — `preserveExactValue`
|
|
435
|
+
|
|
436
|
+
Niche flows (clipboard inspector, raw-prompt debug tool) want
|
|
437
|
+
byte-perfect passthrough. Opt out per-composer:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
const composer = useChatComposer({
|
|
441
|
+
onSubmit,
|
|
442
|
+
preserveExactValue: true, // skip sanitation; raw textarea value goes through
|
|
443
|
+
})
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`canSubmit` still gates on `value.trim().length > 0` in this mode —
|
|
447
|
+
an empty message is rarely intentional even when sanitation is off.
|
|
448
|
+
|
|
345
449
|
## Attachment renderers (registry)
|
|
346
450
|
|
|
347
451
|
`<Attachments>` and `<ChatRoot>` accept a per-type renderer map. Default tile is used when no renderer matches. Plug in heavy viewers (`LazyAudioPlayer`, `LazyImageViewer`, `LazyMap`) host-side without forcing `ui-tools/Chat` to depend on them.
|
|
@@ -527,11 +631,15 @@ useChatAudioPrefs
|
|
|
527
631
|
// Tool-payload dispatch
|
|
528
632
|
dispatchToolPayload, isPlainObject, isLatLng,
|
|
529
633
|
isGeoJSONFeatureCollection, isStringValue,
|
|
530
|
-
type ToolPayloadMatcher, type ToolPayloadFallback
|
|
634
|
+
type ToolPayloadMatcher, type ToolPayloadFallback,
|
|
635
|
+
type ToolPayloadKind, type ToolCallsProps
|
|
531
636
|
|
|
532
637
|
// Lightbox helpers
|
|
533
638
|
collectImageAttachments
|
|
534
639
|
|
|
640
|
+
// Draft sanitation (pre-submit cleanup; see "Draft sanitation" above)
|
|
641
|
+
sanitizeDraft, isSubmittableDraft
|
|
642
|
+
|
|
535
643
|
// Context
|
|
536
644
|
ChatProvider, useChatContext, useChatContextOptional,
|
|
537
645
|
type ComposerHandle
|
|
@@ -563,6 +671,12 @@ LazyChat
|
|
|
563
671
|
| `↓` | Recall next |
|
|
564
672
|
| `Esc` | (host-bound) cancel stream |
|
|
565
673
|
|
|
674
|
+
> **Custom composers built on `MarkdownEditor`:** pass `onSubmit` to
|
|
675
|
+
> the editor for the same behaviour. A React `onKeyDown` wrapper does
|
|
676
|
+
> NOT reliably intercept Enter before Tiptap commits the HardBreak
|
|
677
|
+
> — see `MarkdownEditor/README.md#submit-on-enter` for the full
|
|
678
|
+
> incident write-up and the keymap-extension fix.
|
|
679
|
+
|
|
566
680
|
## Performance
|
|
567
681
|
|
|
568
682
|
- **Token coalescing.** `createTokenBuffer` aggregates stream chunks within ~16ms before dispatching → ≤1 render per frame.
|
|
@@ -623,4 +737,5 @@ Full implementation plan and rationale lives at [`@dev/@refactoring7-chat/`](../
|
|
|
623
737
|
- `WithPersonas` — config-level `user` + `assistant` identity (avatar, name)
|
|
624
738
|
- `MultiUser` — per-message `sender` overrides (multi-user / multi-bot)
|
|
625
739
|
- `WithHideComposer` — `hideComposer` + `footer` approval gate (HITL / agent-pause pattern)
|
|
740
|
+
- `WithRenderAfterCalls` — `renderAfterCalls` (vehicle cards outside panels), `hideToolCalls`, `renderToolCall` (custom per-call renderer) — knob-controlled
|
|
626
741
|
- `Playground` — knobs (latency, streaming, suggestions)
|
|
@@ -24,6 +24,25 @@ export interface ToolCallsProps {
|
|
|
24
24
|
renderStreaming?: (text: string, call: ChatToolCall) => ReactNode;
|
|
25
25
|
/** Single override for all three; specific renderers above take precedence. */
|
|
26
26
|
renderPayload?: (value: unknown, kind: ToolPayloadKind, call: ChatToolCall) => ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* Rendered once **after** all tool-call panels — always visible, outside
|
|
29
|
+
* the collapsible panels. Use for rich UI derived from tool outputs (e.g.
|
|
30
|
+
* vehicle cards, map pins, tax breakdowns). Receives the full calls array
|
|
31
|
+
* so the renderer can aggregate across multiple tool calls.
|
|
32
|
+
*/
|
|
33
|
+
renderAfterCalls?: (calls: ChatToolCall[]) => ReactNode;
|
|
34
|
+
/**
|
|
35
|
+
* Custom renderer for each individual tool-call panel. When provided,
|
|
36
|
+
* replaces the default collapsible `<ToolCallItem>`. Return `null` to
|
|
37
|
+
* suppress a specific call while still letting others render.
|
|
38
|
+
*/
|
|
39
|
+
renderToolCall?: (call: ChatToolCall) => ReactNode;
|
|
40
|
+
/**
|
|
41
|
+
* When `true`, the collapsible tool-call panels are not rendered at all.
|
|
42
|
+
* `renderAfterCalls` still runs — use together to show only rich UI with
|
|
43
|
+
* no raw accordion panels visible.
|
|
44
|
+
*/
|
|
45
|
+
hideToolCalls?: boolean;
|
|
27
46
|
className?: string;
|
|
28
47
|
}
|
|
29
48
|
|
|
@@ -35,23 +54,31 @@ export function ToolCalls({
|
|
|
35
54
|
renderOutput,
|
|
36
55
|
renderStreaming,
|
|
37
56
|
renderPayload,
|
|
57
|
+
renderAfterCalls,
|
|
58
|
+
renderToolCall,
|
|
59
|
+
hideToolCalls = false,
|
|
38
60
|
className,
|
|
39
61
|
}: ToolCallsProps) {
|
|
40
62
|
if (!calls?.length) return null;
|
|
41
63
|
return (
|
|
42
64
|
<div className={cn('mt-2 space-y-1.5', className)}>
|
|
43
|
-
{calls.map((call) =>
|
|
44
|
-
|
|
45
|
-
key={call.id}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
{!hideToolCalls && calls.map((call) =>
|
|
66
|
+
renderToolCall
|
|
67
|
+
? <div key={call.id}>{renderToolCall(call)}</div>
|
|
68
|
+
: (
|
|
69
|
+
<ToolCallItem
|
|
70
|
+
key={call.id}
|
|
71
|
+
call={call}
|
|
72
|
+
defaultExpanded={defaultExpanded}
|
|
73
|
+
expandWhileStreaming={expandWhileStreaming}
|
|
74
|
+
renderInput={renderInput}
|
|
75
|
+
renderOutput={renderOutput}
|
|
76
|
+
renderStreaming={renderStreaming}
|
|
77
|
+
renderPayload={renderPayload}
|
|
78
|
+
/>
|
|
79
|
+
),
|
|
80
|
+
)}
|
|
81
|
+
{renderAfterCalls ? renderAfterCalls(calls) : null}
|
|
55
82
|
</div>
|
|
56
83
|
);
|
|
57
84
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
|
|
14
14
|
import type { ChatAttachment } from '../types';
|
|
15
15
|
import { LIMITS } from '../config';
|
|
16
|
+
import { sanitizeDraft } from '../utils/sanitizeDraft';
|
|
16
17
|
|
|
17
18
|
export interface UseChatComposerOptions {
|
|
18
19
|
onSubmit: (content: string, attachments: ChatAttachment[]) => void | Promise<void>;
|
|
@@ -34,6 +35,14 @@ export interface UseChatComposerOptions {
|
|
|
34
35
|
* exactly as before. Plan64.
|
|
35
36
|
*/
|
|
36
37
|
persistKey?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Skip pre-submit draft sanitation (trim + line-ending normalise +
|
|
40
|
+
* zero-width strip). Default `false` — sanitation matches what
|
|
41
|
+
* ChatGPT / Claude / Telegram ship and is what consumers want 99% of
|
|
42
|
+
* the time. Set to `true` for niche flows that need byte-perfect
|
|
43
|
+
* passthrough (clipboard inspector, raw-prompt debug tool).
|
|
44
|
+
*/
|
|
45
|
+
preserveExactValue?: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export interface UseChatComposerReturn {
|
|
@@ -73,6 +82,7 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
73
82
|
history = { enabled: true, size: LIMITS.composerHistorySize },
|
|
74
83
|
onPasteFiles,
|
|
75
84
|
persistKey,
|
|
85
|
+
preserveExactValue = false,
|
|
76
86
|
} = options;
|
|
77
87
|
|
|
78
88
|
// Hydrate draft from sessionStorage on mount when a key is provided.
|
|
@@ -135,26 +145,39 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
135
145
|
}, []);
|
|
136
146
|
|
|
137
147
|
const submit = useCallback(async () => {
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
// Sanitise BEFORE the empty-guard so a draft of only whitespace
|
|
149
|
+
// (` \n\n `) is treated as empty — saves a round trip for
|
|
150
|
+
// "send" attempts that would produce nothing.
|
|
151
|
+
//
|
|
152
|
+
// sanitizeDraft is intentionally conservative: trim outer
|
|
153
|
+
// whitespace, normalise line endings, strip zero-width chars.
|
|
154
|
+
// It does NOT collapse internal whitespace or cap blank lines
|
|
155
|
+
// (would break code indentation / intentional separators). See
|
|
156
|
+
// utils/sanitizeDraft.ts for the full ruleset + rationale.
|
|
157
|
+
//
|
|
158
|
+
// The cleaned text is what reaches `onSubmit` (matches ChatGPT /
|
|
159
|
+
// Claude / Telegram behaviour — the bubble shows what the user
|
|
160
|
+
// last saw, sans accidental trailing whitespace). Niche callers
|
|
161
|
+
// can opt out via `preserveExactValue`.
|
|
162
|
+
const cleaned = preserveExactValue ? value : sanitizeDraft(value);
|
|
163
|
+
if ((!cleaned && attachments.length === 0) || isSubmitting || disabled) return;
|
|
140
164
|
setIsSubmitting(true);
|
|
141
165
|
try {
|
|
142
|
-
if (history.enabled !== false &&
|
|
166
|
+
if (history.enabled !== false && cleaned) {
|
|
143
167
|
const buf = historyRef.current.items;
|
|
144
|
-
if (buf[buf.length - 1] !==
|
|
145
|
-
buf.push(
|
|
168
|
+
if (buf[buf.length - 1] !== cleaned) {
|
|
169
|
+
buf.push(cleaned);
|
|
146
170
|
if (buf.length > (history.size ?? LIMITS.composerHistorySize)) buf.shift();
|
|
147
171
|
}
|
|
148
172
|
historyRef.current.index = -1;
|
|
149
173
|
}
|
|
150
174
|
const snapshot = [...attachments];
|
|
151
|
-
const text = value;
|
|
152
175
|
reset();
|
|
153
|
-
await onSubmit(
|
|
176
|
+
await onSubmit(cleaned, snapshot);
|
|
154
177
|
} finally {
|
|
155
178
|
setIsSubmitting(false);
|
|
156
179
|
}
|
|
157
|
-
}, [value, attachments, isSubmitting, disabled, history, onSubmit, reset]);
|
|
180
|
+
}, [value, attachments, isSubmitting, disabled, history, onSubmit, reset, preserveExactValue]);
|
|
158
181
|
|
|
159
182
|
const addAttachment = useCallback(
|
|
160
183
|
(a: ChatAttachment) => {
|
|
@@ -236,8 +259,17 @@ export function useChatComposer(options: UseChatComposerOptions): UseChatCompose
|
|
|
236
259
|
[onPasteFiles],
|
|
237
260
|
);
|
|
238
261
|
|
|
262
|
+
// canSubmit mirrors what submit() will actually do — gate the Send
|
|
263
|
+
// button on the post-sanitation result so a whitespace-only draft
|
|
264
|
+
// renders Send as disabled (no false-hope affordance).
|
|
265
|
+
// preserveExactValue callers fall back to raw .trim() — they
|
|
266
|
+
// opted out of sanitation, but we still don't enable Send on a
|
|
267
|
+
// pure-whitespace draft (an empty message is rarely intentional).
|
|
239
268
|
const canSubmit =
|
|
240
|
-
!disabled && !isSubmitting && (
|
|
269
|
+
!disabled && !isSubmitting && (
|
|
270
|
+
(preserveExactValue ? value.trim().length : sanitizeDraft(value).length) > 0
|
|
271
|
+
|| attachments.length > 0
|
|
272
|
+
);
|
|
241
273
|
|
|
242
274
|
return {
|
|
243
275
|
value,
|
package/src/tools/Chat/index.ts
CHANGED
|
@@ -113,6 +113,11 @@ export {
|
|
|
113
113
|
export { useChatLightbox, type UseChatLightboxReturn, type ChatLightboxState } from './hooks';
|
|
114
114
|
export { collectImageAttachments } from './utils/collectImageAttachments';
|
|
115
115
|
|
|
116
|
+
// Draft sanitation — trim, collapse runs, strip zero-width chars.
|
|
117
|
+
// Wire into your composer's submit handler / Send-button gate so
|
|
118
|
+
// empty-after-cleanup drafts don't fire bogus messages.
|
|
119
|
+
export { sanitizeDraft, isSubmittableDraft } from './utils/sanitizeDraft';
|
|
120
|
+
|
|
116
121
|
// Dev logger (consola-based, namespace "chat:*")
|
|
117
122
|
export { getChatLogger, type ChatLogger, type ChatLogScope } from './core/logger';
|
|
118
123
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitizeDraft — minimal pre-submit cleanup for chat-composer drafts.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the conservative behaviour ChatGPT / Claude / Telegram
|
|
5
|
+
* actually ship: clean the obvious junk the user didn't intend to
|
|
6
|
+
* send, touch nothing that *could* be intentional.
|
|
7
|
+
*
|
|
8
|
+
* **What we DO touch:**
|
|
9
|
+
*
|
|
10
|
+
* 1. Trim leading/trailing whitespace (spaces, tabs, newlines,
|
|
11
|
+
* NBSP). The user typing `\n\n hello \n` meant `hello`.
|
|
12
|
+
* 2. Normalise line endings — `\r\n` / `\r` → `\n`. Pasted Windows
|
|
13
|
+
* / old-mac text gets the same internal shape, so the LLM
|
|
14
|
+
* tokeniser and markdown renderer see one canonical form.
|
|
15
|
+
* 3. Strip zero-width / invisible characters that web-paste
|
|
16
|
+
* smuggles in: ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D),
|
|
17
|
+
* BOM / ZWNBSP (U+FEFF). They're invisible, break LLM
|
|
18
|
+
* tokenisation, and the user never meant to type them.
|
|
19
|
+
*
|
|
20
|
+
* **What we DO NOT touch (and why):**
|
|
21
|
+
*
|
|
22
|
+
* - **Internal whitespace runs** (3+ spaces, tabs, blank lines).
|
|
23
|
+
* Code indentation depends on these. ChatGPT preserves them as
|
|
24
|
+
* typed — " if (x):\n return" stays four-space-indented.
|
|
25
|
+
* Collapsing them is the path to subtly broken code snippets.
|
|
26
|
+
*
|
|
27
|
+
* - **Bidi override marks** (U+200E LRM, U+200F RLM, U+202A..U+202E).
|
|
28
|
+
* Legitimately used in Arabic / Hebrew / mixed-direction text.
|
|
29
|
+
* Stripping silently breaks RTL users. If a specific deployment
|
|
30
|
+
* wants to block them as a security measure, do it at that layer
|
|
31
|
+
* with explicit user-visible feedback.
|
|
32
|
+
*
|
|
33
|
+
* - **Tabs vs spaces** beyond rule 1. Could be either code or
|
|
34
|
+
* prose; without parsing markdown we can't tell.
|
|
35
|
+
*
|
|
36
|
+
* - **Emoji, mentions, URLs, code spans** — passthrough text.
|
|
37
|
+
*
|
|
38
|
+
* The function is intentionally tiny — every rule earns its keep
|
|
39
|
+
* with a concrete "user pasted X from Y, got nonsense" story.
|
|
40
|
+
*
|
|
41
|
+
* **Idempotent**: `sanitizeDraft(sanitizeDraft(x)) === sanitizeDraft(x)`.
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeDraft(input: string): string {
|
|
44
|
+
if (!input) return '';
|
|
45
|
+
|
|
46
|
+
// Strip zero-width invisibles. Done FIRST so the trim below sees
|
|
47
|
+
// the real content edges — a stray ZWSP at the start would
|
|
48
|
+
// otherwise count as non-whitespace and survive the trim.
|
|
49
|
+
// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+FEFF BOM/ZWNBSP.
|
|
50
|
+
let s = input.replace(/[]/g, '');
|
|
51
|
+
|
|
52
|
+
// Normalise line endings.
|
|
53
|
+
s = s.replace(/\r\n?/g, '\n');
|
|
54
|
+
|
|
55
|
+
// Trim outer whitespace (includes \n, \t, NBSP via String.trim).
|
|
56
|
+
s = s.trim();
|
|
57
|
+
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convenience predicate: true when the draft is non-empty AFTER
|
|
63
|
+
* sanitation. Use to gate Send buttons / Enter submits so an empty
|
|
64
|
+
* or whitespace-only draft never produces a real message.
|
|
65
|
+
*
|
|
66
|
+
* Cheaper than sanitizeDraft(input).length > 0 only marginally —
|
|
67
|
+
* we still allocate the cleaned string. Kept as a named helper for
|
|
68
|
+
* call-site clarity.
|
|
69
|
+
*/
|
|
70
|
+
export function isSubmittableDraft(input: string): boolean {
|
|
71
|
+
return sanitizeDraft(input).length > 0;
|
|
72
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'lucide-react';
|
|
14
14
|
import { createMentionSuggestion } from './createMentionSuggestion';
|
|
15
15
|
import { mentionPresets } from './mentionPresets';
|
|
16
|
+
import { SubmitOnEnter } from './submitOnEnter';
|
|
16
17
|
import type { MentionAttrs, MentionConfig } from './types';
|
|
17
18
|
import './styles.css';
|
|
18
19
|
|
|
@@ -68,6 +69,25 @@ export interface MarkdownEditorProps {
|
|
|
68
69
|
mentions?: MentionConfig;
|
|
69
70
|
/** Called when mentioned IDs change */
|
|
70
71
|
onMentionIdsChange?: (ids: string[]) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Called when the user presses Enter (without Shift, no IME
|
|
74
|
+
* composition, no mention popover open). When set, Enter submits
|
|
75
|
+
* and Shift+Enter inserts a newline — ChatGPT / Telegram chat
|
|
76
|
+
* behaviour. When omitted, Enter behaves as Tiptap default
|
|
77
|
+
* (HardBreak).
|
|
78
|
+
*
|
|
79
|
+
* Implementation lives in `submitOnEnter.ts` — it's a Tiptap
|
|
80
|
+
* keymap extension, NOT a React wrapper handler. Wrapper-level
|
|
81
|
+
* onKeyDown fires AFTER ProseMirror's keymap commits HardBreak in
|
|
82
|
+
* the same tick; routing through the extension lets us intercept
|
|
83
|
+
* before HardBreak runs.
|
|
84
|
+
*
|
|
85
|
+
* Return value (optional): truthy / undefined = consume the key
|
|
86
|
+
* (default). Return `false` from onSubmit to let Tiptap fall
|
|
87
|
+
* through to HardBreak — useful for guards like "don't submit an
|
|
88
|
+
* empty draft".
|
|
89
|
+
*/
|
|
90
|
+
onSubmit?: () => boolean | void;
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
// ── Component ──
|
|
@@ -82,7 +102,15 @@ export function MarkdownEditor({
|
|
|
82
102
|
showToolbar = true,
|
|
83
103
|
mentions,
|
|
84
104
|
onMentionIdsChange,
|
|
105
|
+
onSubmit,
|
|
85
106
|
}: MarkdownEditorProps) {
|
|
107
|
+
// Keep the latest onSubmit in a ref so the Tiptap extension's
|
|
108
|
+
// keymap closure always calls the freshest handler — Tiptap's
|
|
109
|
+
// useEditor initialises extensions ONCE on first render. Without
|
|
110
|
+
// the ref the extension would call a stale onSubmit (e.g. one
|
|
111
|
+
// that references an outdated `value`).
|
|
112
|
+
const onSubmitRef = useRef(onSubmit);
|
|
113
|
+
onSubmitRef.current = onSubmit;
|
|
86
114
|
const isExternalUpdate = useRef(false);
|
|
87
115
|
|
|
88
116
|
// ── Dev-mode trap detector ──
|
|
@@ -115,6 +143,18 @@ export function MarkdownEditor({
|
|
|
115
143
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
|
116
144
|
Placeholder.configure({ placeholder }),
|
|
117
145
|
Markdown,
|
|
146
|
+
// SubmitOnEnter — when the consumer wired an onSubmit, intercept
|
|
147
|
+
// Enter at the keymap level (before StarterKit's HardBreak).
|
|
148
|
+
// The extension calls through `onSubmitRef.current` so handler
|
|
149
|
+
// identity changes don't require an editor rebuild. See
|
|
150
|
+
// submitOnEnter.ts for the keymap-vs-wrapper-handler rationale.
|
|
151
|
+
SubmitOnEnter.configure({
|
|
152
|
+
onSubmit: () => {
|
|
153
|
+
const h = onSubmitRef.current;
|
|
154
|
+
if (!h) return false; // no handler → let Tiptap insert HardBreak
|
|
155
|
+
return h();
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
118
158
|
];
|
|
119
159
|
|
|
120
160
|
if (mentions) {
|
|
@@ -58,6 +58,7 @@ import '@djangocfg/ui-tools/dist.css';
|
|
|
58
58
|
| `showToolbar` | `boolean` | `true` | Show formatting toolbar |
|
|
59
59
|
| `mentions` | `MentionConfig` | — | `@`-mention autocomplete config |
|
|
60
60
|
| `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
|
|
61
|
+
| `onSubmit` | `() => boolean \| void` | — | Enter handler — when set, Enter submits and Shift+Enter inserts a newline (ChatGPT / Telegram chat behaviour). Return `false` to fall back to default HardBreak. See [Submit on Enter](#submit-on-enter) below. |
|
|
61
62
|
|
|
62
63
|
### `MentionConfig` fields
|
|
63
64
|
|
|
@@ -142,6 +143,39 @@ Either `id` or `label` may be empty strings if upstream config didn't populate t
|
|
|
142
143
|
|
|
143
144
|
> Mentions are write-only: the markdown isn't parsed back into mention nodes on `setContent`. After submit/reset, the editor receives a plain string — fine for chat composers.
|
|
144
145
|
|
|
146
|
+
## Submit on Enter
|
|
147
|
+
|
|
148
|
+
For chat composers you usually want **Enter = send**, **Shift+Enter = newline** — ChatGPT / Telegram / Slack behaviour. Pass an `onSubmit` and that's what you get:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<MarkdownEditor
|
|
152
|
+
value={text}
|
|
153
|
+
onChange={setText}
|
|
154
|
+
onSubmit={() => {
|
|
155
|
+
if (!text.trim()) return false // fall back to HardBreak
|
|
156
|
+
void send(text)
|
|
157
|
+
// returning undefined (or true) consumes the key — newline isn't inserted
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### How it works (and why a Tiptap extension, not a React onKeyDown)
|
|
163
|
+
|
|
164
|
+
The implementation lives in `submitOnEnter.ts` — a Tiptap `Extension` that registers a keyboard shortcut via `addKeyboardShortcuts`. It runs **inside ProseMirror's keymap pipeline at higher priority than StarterKit's HardBreak**, so we intercept Enter **before** the hard-break transaction is dispatched.
|
|
165
|
+
|
|
166
|
+
A naïve wrapper handler (`<div onKeyDownCapture={...}>` calling `preventDefault`) does NOT work reliably: ProseMirror's keymap is a plugin inside the editor, which fires its handler in the same event tick as the React capture phase but **commits the HardBreak transaction before React's stopPropagation gets a chance to matter**. The user sees a hard-break flash in, then the next Enter submits — the "first Enter inserts newline" bug we shipped before this extension landed.
|
|
167
|
+
|
|
168
|
+
### Behaviour details
|
|
169
|
+
|
|
170
|
+
| Key | Behaviour |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `Enter` | Calls `onSubmit()`. Returns `true`/`undefined` ⇒ consume. Returns `false` ⇒ fall through to HardBreak. |
|
|
173
|
+
| `Shift+Enter` | Always inserts a newline. Bound to `() => false` so the chain falls through cleanly. |
|
|
174
|
+
| Mention popover open | Enter is given to the suggestion plugin (it picks the active item) — detected by querying `.markdown-mention-list`. |
|
|
175
|
+
| IME composition | Native browser composition events are not intercepted (Tiptap handles them upstream). |
|
|
176
|
+
|
|
177
|
+
The handler is captured via a ref inside `MarkdownEditor`, so swapping `onSubmit` between renders is safe — the latest closure always fires, no editor rebuild needed.
|
|
178
|
+
|
|
145
179
|
## Dependencies
|
|
146
180
|
|
|
147
181
|
All Tiptap packages and `@floating-ui/dom` are direct dependencies — no extra installs needed.
|