@djangocfg/ui-tools 2.1.430 → 2.1.432
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/tree/index.cjs +43 -8
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +31 -1
- package/dist/tree/index.d.ts +31 -1
- package/dist/tree/index.mjs +43 -8
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +28 -14
- package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
- package/src/common/CodePanelHeader/index.ts +1 -0
- package/src/common/blocks/BlockError.tsx +46 -0
- package/src/common/blocks/index.ts +34 -0
- package/src/common/blocks/schemas.ts +143 -0
- package/src/common/blocks/types.ts +76 -0
- package/src/common/chips/README.md +67 -0
- package/src/common/chips/UrlChip.tsx +105 -0
- package/src/common/chips/index.ts +20 -0
- package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/common/link-preview/index.ts +12 -0
- package/src/common/link-preview/types.ts +44 -0
- package/src/common/tiptap/index.ts +10 -0
- package/src/common/tiptap/useNodeAttrs.ts +128 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
- package/src/tools/chat/README.md +109 -2
- package/src/tools/chat/composer/Composer.tsx +45 -12
- package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
- package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
- package/src/tools/chat/composer/index.ts +9 -0
- package/src/tools/chat/composer/textToAttachment.ts +69 -0
- package/src/tools/chat/composer/types.ts +13 -0
- package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
- package/src/tools/chat/constants.ts +9 -2
- package/src/tools/chat/core/extractFirstUrl.ts +45 -0
- package/src/tools/chat/core/reducer.ts +32 -8
- package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
- package/src/tools/chat/core/transport/mock.ts +4 -1
- package/src/tools/chat/hooks/useChat.ts +20 -20
- package/src/tools/chat/lazy.tsx +10 -0
- package/src/tools/chat/messages/Attachments.tsx +335 -46
- package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
- package/src/tools/chat/messages/MessageBubble.tsx +175 -31
- package/src/tools/chat/messages/MessageList.tsx +107 -21
- package/src/tools/chat/messages/ToolCalls.tsx +28 -13
- package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
- package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
- package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
- package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
- package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
- package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
- package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
- package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
- package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
- package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
- package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
- package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
- package/src/tools/chat/messages/index.ts +13 -0
- package/src/tools/chat/public.ts +5 -1
- package/src/tools/chat/shell/ChatRoot.tsx +29 -0
- package/src/tools/chat/styles/bubbleTokens.ts +18 -0
- package/src/tools/chat/styles/index.ts +1 -0
- package/src/tools/chat/types/attachment.ts +38 -3
- package/src/tools/chat/types/block.ts +56 -9
- package/src/tools/chat/types/blockSchemas.ts +197 -0
- package/src/tools/chat/types/config.ts +33 -0
- package/src/tools/chat/types/events.ts +14 -7
- package/src/tools/chat/types/index.ts +4 -2
- package/src/tools/chat/types/message.ts +7 -0
- package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
- package/src/tools/data/Tree/README.md +30 -0
- package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
- package/src/tools/data/Tree/context/menu/render.tsx +56 -27
- package/src/tools/data/Tree/data/appearance.ts +65 -3
- package/src/tools/data/Tree/data/index.ts +2 -0
- package/src/tools/data/Tree/index.tsx +2 -0
- package/src/tools/dev/Map/README.md +30 -345
- package/src/tools/dev/Map/cards/index.ts +6 -0
- package/src/tools/dev/Map/cards/types.ts +67 -0
- package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
- package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
- package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
- package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
- package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
- package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
- package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
- package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
- package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
- package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
- package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
- package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
- package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
- package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
- package/src/tools/dev/Map/components/MapControls.tsx +27 -0
- package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
- package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
- package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
- package/src/tools/dev/Map/components/index.ts +35 -1
- package/src/tools/dev/Map/geocode/index.ts +2 -0
- package/src/tools/dev/Map/geocode/photon.ts +265 -0
- package/src/tools/dev/Map/hooks/index.ts +37 -0
- package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
- package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
- package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
- package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
- package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
- package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
- package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
- package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
- package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
- package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
- package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
- package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
- package/src/tools/dev/Map/index.ts +59 -2
- package/src/tools/dev/Map/layers/index.ts +1 -0
- package/src/tools/dev/Map/layers/symbol.ts +93 -0
- package/src/tools/dev/Map/lazy.tsx +120 -0
- package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
- package/src/tools/dev/Map/styles/index.ts +62 -0
- package/src/tools/dev/Map/styles/terrain.ts +29 -0
- package/src/tools/dev/Map/types.ts +31 -3
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
- package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
- package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
- package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
- package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
- package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
- package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
- package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
- package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
- package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
- package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
- package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
- package/src/tools/forms/MarkdownEditor/README.md +84 -0
- package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
- package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
- package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
- package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
- package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
- package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
- package/src/tools/forms/MarkdownEditor/index.ts +19 -0
- package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
- package/src/tools/forms/MarkdownEditor/styles.css +134 -2
- package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
- package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
- package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
- package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
- package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
- package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
- package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
- package/src/tools/forms/NotionEditor/README.md +109 -188
- package/src/tools/forms/NotionEditor/extensions.ts +15 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
- package/src/tools/forms/NotionEditor/styles.css +25 -0
- package/src/tools/forms/NotionEditor/types.ts +9 -0
- package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
- package/src/tools/input/SpeechRecognition/types.ts +10 -0
- package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
- package/src/tools/media/ImageViewer/README.md +9 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
- package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
- package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
- package/src/tools/media/ImageViewer/components/index.ts +2 -0
- package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
- package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
- package/src/tools/media/ImageViewer/types.ts +10 -0
- package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
- package/src/tools/media/ImageViewer/utils/index.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
- package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
- package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
- package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
- package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
- package/src/tools/visual/charts/SmoothLine/README.md +97 -0
- package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
- package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
- package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
- package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
- package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
- package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
- package/src/tools/visual/indicators/Fps/types.ts +1 -1
- package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
- package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
|
@@ -94,21 +94,34 @@ const SIZE_CLASSES: Record<ComposerSize, {
|
|
|
94
94
|
inlineSlot: string;
|
|
95
95
|
}> = {
|
|
96
96
|
sm: {
|
|
97
|
-
|
|
97
|
+
// Symmetric corner inset — px == py so the trailing send button sits
|
|
98
|
+
// equidistant from the surface's right and bottom edges.
|
|
99
|
+
surface: 'rounded-xl px-2 py-2',
|
|
98
100
|
textarea: 'min-h-7 max-h-44 px-1.5 py-1',
|
|
99
101
|
text: 'text-sm',
|
|
100
102
|
containerPadding: 'px-2 pt-1.5 pb-[max(0.375rem,env(safe-area-inset-bottom))]',
|
|
101
103
|
inlineSlot: '[&>:not(textarea)]:h-7',
|
|
102
104
|
},
|
|
103
105
|
md: {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
// Symmetric corner inset — px == py so the trailing send button sits
|
|
107
|
+
// equidistant from the surface's right and bottom edges (was py-1.5,
|
|
108
|
+
// which left the button closer to the bottom than the right).
|
|
109
|
+
surface: 'rounded-2xl px-2 py-2',
|
|
110
|
+
// ~one-line tall (min-h-8) with tight, balanced vertical padding.
|
|
111
|
+
// `py-1.5` (6px) stacked under the surface's own `py-1.5` read like a
|
|
112
|
+
// phantom empty line under a single-line input — `py-1` keeps the
|
|
113
|
+
// text vertically centred without the extra bottom gap.
|
|
114
|
+
textarea: 'min-h-8 max-h-60 px-2 py-1',
|
|
115
|
+
// Comfortable 15px chat body. `text-base sm:text-sm` collapsed to
|
|
116
|
+
// 14px and read too small; 15px matches the rich-editor baseline.
|
|
117
|
+
text: 'text-[15px]',
|
|
107
118
|
containerPadding: 'px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]',
|
|
108
119
|
inlineSlot: '[&>:not(textarea)]:h-9',
|
|
109
120
|
},
|
|
110
121
|
lg: {
|
|
111
|
-
|
|
122
|
+
// Symmetric corner inset — px == py so the trailing send button sits
|
|
123
|
+
// equidistant from the surface's right and bottom edges.
|
|
124
|
+
surface: 'rounded-2xl px-3 py-3',
|
|
112
125
|
textarea: 'min-h-12 max-h-72 px-2.5 py-2',
|
|
113
126
|
text: 'text-base',
|
|
114
127
|
containerPadding: 'px-3.5 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))]',
|
|
@@ -133,7 +146,9 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
133
146
|
}> = {
|
|
134
147
|
compact: { surface: '', textarea: '', text: '', containerPadding: '' },
|
|
135
148
|
full: {
|
|
136
|
-
|
|
149
|
+
// Symmetric corner inset — px == py so the trailing send button sits
|
|
150
|
+
// equidistant from the surface's right and bottom edges.
|
|
151
|
+
surface: 'rounded-3xl px-3 py-3',
|
|
137
152
|
textarea: 'min-h-10 max-h-80 px-2.5 py-2',
|
|
138
153
|
text: 'text-base',
|
|
139
154
|
containerPadding: 'px-4 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]',
|
|
@@ -337,6 +352,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
337
352
|
dragDepth.current = 0;
|
|
338
353
|
setIsDragging(false);
|
|
339
354
|
const dropped = Array.from(e.dataTransfer.files ?? []);
|
|
355
|
+
// eslint-disable-next-line no-console
|
|
356
|
+
console.log('[composer] handleDrop files=%d names=%o', dropped.length, dropped.map((f) => f.name));
|
|
340
357
|
if (dropped.length > 0) attachHandle.attachFiles(dropped);
|
|
341
358
|
};
|
|
342
359
|
|
|
@@ -485,7 +502,20 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
485
502
|
// ── Inline (compact single-row) layout ─────────────────────────────────
|
|
486
503
|
// Drag-drop handlers shared by both layouts. No-op when attaching is
|
|
487
504
|
// disabled — the handlers themselves early-return.
|
|
488
|
-
|
|
505
|
+
//
|
|
506
|
+
// `data-wails-dropzone` is load-bearing inside the Wails desktop shell:
|
|
507
|
+
// its runtime gates DOM file drops on a per-element basis. On `dragover`
|
|
508
|
+
// it walks `elementFromPoint(...).closest('[data-wails-dropzone]')` and
|
|
509
|
+
// sets `dataTransfer.dropEffect = 'copy'` ONLY when the cursor is over a
|
|
510
|
+
// marked element — otherwise it forces `dropEffect = 'none'`, and the
|
|
511
|
+
// browser then REJECTS the `drop` on that element (the React `onDrop`
|
|
512
|
+
// never fires). Marking the composer surface makes the runtime allow the
|
|
513
|
+
// drop here, exactly like the debug drop-test harness. Harmless no-op in
|
|
514
|
+
// a plain browser (it's just an unknown data-attribute there).
|
|
515
|
+
const dragProps: React.HTMLAttributes<HTMLDivElement> & {
|
|
516
|
+
'data-wails-dropzone': string;
|
|
517
|
+
} = {
|
|
518
|
+
'data-wails-dropzone': '',
|
|
489
519
|
onDragEnter: handleDragEnter,
|
|
490
520
|
onDragOver: handleDragOver,
|
|
491
521
|
onDragLeave: handleDragLeave,
|
|
@@ -615,12 +645,15 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
615
645
|
ref={surfaceRef}
|
|
616
646
|
onMouseDown={handleSurfaceMouseDown}
|
|
617
647
|
className={cn(
|
|
618
|
-
// Raised input panel —
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
648
|
+
// Raised input panel — a *gentle* elevation over the chat
|
|
649
|
+
// background, not a hard box. `card` is the "slightly elevated
|
|
650
|
+
// surface" token (one step up from `background`); `muted` is the
|
|
651
|
+
// chips/fills token and sat far too light, reading as a
|
|
652
|
+
// high-contrast block on the dark macOS theme. A softened
|
|
653
|
+
// hairline ring (`border/60`) keeps the edge whisper-quiet
|
|
654
|
+
// (Gemini/ChatGPT/Claude style). Focus firms the ring up.
|
|
622
655
|
'flex flex-col rounded-2xl text-foreground',
|
|
623
|
-
'bg-
|
|
656
|
+
'bg-card ring-1 ring-border/60',
|
|
624
657
|
'focus-within:ring-ring/50',
|
|
625
658
|
'transition-colors',
|
|
626
659
|
// The whole surface reads as a text field — the padding around the
|
|
@@ -12,11 +12,23 @@ import {
|
|
|
12
12
|
import { useRegisterComposer } from '../hooks/useAutoFocusOnStreamEnd';
|
|
13
13
|
import type { ComposerSize, ComposerTextareaProps } from './types';
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Single-row min-height FLOOR per composer size (px).
|
|
17
|
+
*
|
|
18
|
+
* This is a floor, NOT the row height — the empty `<p>`'s own line box
|
|
19
|
+
* defines the real single-row height. The floor must stay BELOW one line
|
|
20
|
+
* box (15px text × line-height) so it can never inflate the editor past
|
|
21
|
+
* one line: when a host overrides the editor leading (e.g. cmdop pins
|
|
22
|
+
* `.ProseMirror` to `leading-normal` ≈ 22.5px), a 24/28px min-height used
|
|
23
|
+
* to sit ABOVE the line box and the empty composer read as having a
|
|
24
|
+
* phantom extra line under the caret. Keeping every floor ≤ the tightest
|
|
25
|
+
* expected single line (≈22px) means the line box always wins and the
|
|
26
|
+
* single-row composer is exactly one line tall in every host.
|
|
27
|
+
*/
|
|
16
28
|
const MIN_HEIGHT: Record<ComposerSize, number> = {
|
|
17
|
-
sm:
|
|
18
|
-
md:
|
|
19
|
-
lg:
|
|
29
|
+
sm: 18,
|
|
30
|
+
md: 20,
|
|
31
|
+
lg: 22,
|
|
20
32
|
};
|
|
21
33
|
|
|
22
34
|
export interface ComposerRichTextareaProps extends ComposerTextareaProps {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { FileText } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@djangocfg/ui-core/components';
|
|
12
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
import { MarkdownMessage } from '../../dev/code/MarkdownMessage';
|
|
15
|
+
|
|
16
|
+
export interface PastedTextDialogProps {
|
|
17
|
+
/** Whether the dialog is open. */
|
|
18
|
+
open: boolean;
|
|
19
|
+
/** Open-state change handler (Esc / overlay click / close button). */
|
|
20
|
+
onOpenChange: (open: boolean) => void;
|
|
21
|
+
/** The raw pasted text to preview. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Short title shown in the header (the chip's derived title). */
|
|
24
|
+
title?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read-only preview for a "Pasted text" attachment chunk.
|
|
29
|
+
*
|
|
30
|
+
* Renders the raw paste through `MarkdownMessage` — the same chat-tuned
|
|
31
|
+
* renderer used for message bubbles, so fenced code blocks, tables and
|
|
32
|
+
* GFM all come out formatted, with the plain-text fast path kicking in
|
|
33
|
+
* for prose-only pastes. View-only by design: there is no edit affordance.
|
|
34
|
+
*
|
|
35
|
+
* Built on ui-core's radix `Dialog` primitive with local open-state — it
|
|
36
|
+
* does not touch the global `dialog-service` (that service is shaped for
|
|
37
|
+
* imperative string-message alerts/confirms, not arbitrary content). The
|
|
38
|
+
* caller owns `open` so the chip can drive it from a click.
|
|
39
|
+
*/
|
|
40
|
+
export function PastedTextDialog({
|
|
41
|
+
open,
|
|
42
|
+
onOpenChange,
|
|
43
|
+
text,
|
|
44
|
+
title,
|
|
45
|
+
}: PastedTextDialogProps) {
|
|
46
|
+
// A rough size hint for the header (chars / KB) — cheap, derived.
|
|
47
|
+
const meta = useMemo(() => {
|
|
48
|
+
const chars = text.length;
|
|
49
|
+
const kb = new Blob([text]).size / 1024;
|
|
50
|
+
return kb >= 1
|
|
51
|
+
? `${chars.toLocaleString()} chars · ${kb.toFixed(1)} KB`
|
|
52
|
+
: `${chars.toLocaleString()} chars`;
|
|
53
|
+
}, [text]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
57
|
+
<DialogContent
|
|
58
|
+
className={cn(
|
|
59
|
+
'flex max-h-[80dvh] w-full max-w-2xl flex-col gap-3 p-0',
|
|
60
|
+
)}
|
|
61
|
+
// This is a read-only preview — there is no primary action to
|
|
62
|
+
// focus. radix would otherwise autofocus the first focusable node,
|
|
63
|
+
// which is the (visually hover-hidden) "Copy" button inside the
|
|
64
|
+
// markdown code block — surfacing a stray focus ring on open.
|
|
65
|
+
// Prevent the default and keep focus on the panel itself; Esc /
|
|
66
|
+
// overlay click still dismiss, and Tab reaches controls normally.
|
|
67
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
68
|
+
>
|
|
69
|
+
<DialogHeader className="shrink-0 space-y-1 border-b border-border px-5 pb-3 pt-5 text-left">
|
|
70
|
+
<DialogTitle className="flex items-center gap-2 text-base">
|
|
71
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
|
|
72
|
+
<FileText aria-hidden className="size-4" />
|
|
73
|
+
</span>
|
|
74
|
+
<span className="min-w-0 truncate">{title ?? 'Pasted text'}</span>
|
|
75
|
+
</DialogTitle>
|
|
76
|
+
<p className="pl-9 text-xs text-muted-foreground">{meta}</p>
|
|
77
|
+
</DialogHeader>
|
|
78
|
+
|
|
79
|
+
{/* Scrollable body — `MarkdownMessage` formats the paste. */}
|
|
80
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
|
|
81
|
+
<MarkdownMessage content={text} size="sm" />
|
|
82
|
+
</div>
|
|
83
|
+
</DialogContent>
|
|
84
|
+
</Dialog>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default PastedTextDialog;
|
|
@@ -40,6 +40,15 @@ export {
|
|
|
40
40
|
} from './useComposerAttach';
|
|
41
41
|
export { useComposerAttachContext } from './AttachContext';
|
|
42
42
|
export { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
43
|
+
export {
|
|
44
|
+
textToAttachment,
|
|
45
|
+
deriveTextTitle,
|
|
46
|
+
DEFAULT_PASTE_TEXT_THRESHOLD,
|
|
47
|
+
} from './textToAttachment';
|
|
48
|
+
export {
|
|
49
|
+
PastedTextDialog,
|
|
50
|
+
type PastedTextDialogProps,
|
|
51
|
+
} from './PastedTextDialog';
|
|
43
52
|
export {
|
|
44
53
|
parseSlashState,
|
|
45
54
|
filterCommands,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `string → ChatAttachment` — the producer for "Pasted text" chunks.
|
|
3
|
+
*
|
|
4
|
+
* When a long paste is converted into an attachment chip instead of
|
|
5
|
+
* being dumped into the textarea (ChatGPT/Claude behaviour), the raw
|
|
6
|
+
* text is minted into a `type:'text'` attachment. Its payload lives in
|
|
7
|
+
* `text` (exact, newline-preserving); `url` is empty since there is no
|
|
8
|
+
* blob backing it.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `fileToAttachment` so the picker, drag-drop, file-paste and
|
|
11
|
+
* text-paste paths all funnel into the same `ChatAttachment` shape.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createId } from '../core/ids';
|
|
15
|
+
import type { ChatAttachment } from '../types';
|
|
16
|
+
|
|
17
|
+
/** Default character threshold above which a paste becomes a chunk. */
|
|
18
|
+
export const DEFAULT_PASTE_TEXT_THRESHOLD = 2000;
|
|
19
|
+
|
|
20
|
+
/** Max length of the auto-derived title (first non-empty line). */
|
|
21
|
+
const TITLE_MAX = 32;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Derive a short, human-readable title from the pasted text — the first
|
|
25
|
+
* non-empty line, collapsed and ellipsised (e.g. "Valuing the Bali Vec…").
|
|
26
|
+
* Falls back to a generic label for whitespace-only / empty input.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveTextTitle(text: string): string {
|
|
29
|
+
const firstLine = text
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((l) => l.trim())
|
|
32
|
+
.find((l) => l.length > 0);
|
|
33
|
+
if (!firstLine) return 'Pasted text';
|
|
34
|
+
// Strip leading markdown markers (heading `#`, blockquote `>`, list
|
|
35
|
+
// bullets `-`/`*`/`+`) so a markdown paste reads as plain text on the
|
|
36
|
+
// chip — the title is a label, not source. The payload is untouched.
|
|
37
|
+
const stripped = firstLine
|
|
38
|
+
.replace(/^#{1,6}\s+/, '')
|
|
39
|
+
.replace(/^>\s+/, '')
|
|
40
|
+
.replace(/^[-*+]\s+/, '')
|
|
41
|
+
.trim();
|
|
42
|
+
// Collapse internal runs of whitespace so a title never wraps oddly.
|
|
43
|
+
const collapsed = (stripped || firstLine).replace(/\s+/g, ' ');
|
|
44
|
+
return collapsed.length > TITLE_MAX
|
|
45
|
+
? `${collapsed.slice(0, TITLE_MAX).trimEnd()}…`
|
|
46
|
+
: collapsed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert a pasted string into a `type:'text'` `ChatAttachment`.
|
|
51
|
+
*
|
|
52
|
+
* - `name` — short title derived from the first line (shown on the chip).
|
|
53
|
+
* - `text` — the full, exact payload (sent to `onSubmit` alongside the
|
|
54
|
+
* message content).
|
|
55
|
+
* - `url` — empty: text chunks have no blob backing.
|
|
56
|
+
* - `sizeBytes` — UTF-8 byte length, for an optional "N chars / KB" hint.
|
|
57
|
+
*/
|
|
58
|
+
export function textToAttachment(text: string): ChatAttachment {
|
|
59
|
+
return {
|
|
60
|
+
id: createId('att'),
|
|
61
|
+
type: 'text',
|
|
62
|
+
url: '',
|
|
63
|
+
name: deriveTextTitle(text),
|
|
64
|
+
mimeType: 'text/plain',
|
|
65
|
+
sizeBytes: new Blob([text]).size,
|
|
66
|
+
status: 'ready',
|
|
67
|
+
text,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -198,6 +198,19 @@ export interface ComposerAttachConfig {
|
|
|
198
198
|
pasteEnabled?: boolean;
|
|
199
199
|
/** Called when a file is rejected (size / type / count). */
|
|
200
200
|
onReject?: (file: File, reason: 'size' | 'type' | 'count') => void;
|
|
201
|
+
/**
|
|
202
|
+
* Convert a long plain-text paste (⌘V / Ctrl+V) into a "Pasted text"
|
|
203
|
+
* attachment chip instead of dumping it into the textarea — the
|
|
204
|
+
* ChatGPT/Claude behaviour. Default `true`. Short pastes (below
|
|
205
|
+
* {@link pasteTextThreshold}) always go straight into the textarea.
|
|
206
|
+
*/
|
|
207
|
+
pasteTextAsChunk?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Character threshold above which a plain-text paste becomes a chunk.
|
|
210
|
+
* Default `2000` (matches Claude / LibreChat). Ignored when
|
|
211
|
+
* `pasteTextAsChunk` is `false`.
|
|
212
|
+
*/
|
|
213
|
+
pasteTextThreshold?: number;
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
/** Imperative handle the attach pipeline exposes to the composer + menu. */
|
|
@@ -5,6 +5,10 @@ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
|
|
|
5
5
|
import { buildAcceptString, useClipboardPaste } from '../../forms/Uploader';
|
|
6
6
|
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
7
7
|
import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_PASTE_TEXT_THRESHOLD,
|
|
10
|
+
textToAttachment,
|
|
11
|
+
} from './textToAttachment';
|
|
8
12
|
import type {
|
|
9
13
|
ComposerAcceptType,
|
|
10
14
|
ComposerAttachConfig,
|
|
@@ -59,6 +63,8 @@ export function useComposerAttach({
|
|
|
59
63
|
maxSizeBytes,
|
|
60
64
|
multiple = true,
|
|
61
65
|
pasteEnabled = true,
|
|
66
|
+
pasteTextAsChunk = true,
|
|
67
|
+
pasteTextThreshold = DEFAULT_PASTE_TEXT_THRESHOLD,
|
|
62
68
|
uploadFn,
|
|
63
69
|
onReject,
|
|
64
70
|
} = config;
|
|
@@ -187,6 +193,47 @@ export function useComposerAttach({
|
|
|
187
193
|
pasteScopeRef,
|
|
188
194
|
);
|
|
189
195
|
|
|
196
|
+
// Long-text paste → "Pasted text" chunk. A separate listener from the
|
|
197
|
+
// file path above: `useClipboardPaste` deliberately ignores pastes
|
|
198
|
+
// that carry text into a text field (so it doesn't hijack typing), so
|
|
199
|
+
// it never sees this case. We intercept here, before the browser
|
|
200
|
+
// inserts the text into the textarea, and mint a `type:'text'`
|
|
201
|
+
// attachment instead — the ChatGPT/Claude behaviour.
|
|
202
|
+
const pasteTextEnabled = pasteEnabled && pasteTextAsChunk && !disabled;
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!pasteTextEnabled) return undefined;
|
|
205
|
+
const target: HTMLElement | Document = pasteScopeRef?.current ?? document;
|
|
206
|
+
|
|
207
|
+
const onPaste = (e: Event) => {
|
|
208
|
+
const ev = e as ClipboardEvent;
|
|
209
|
+
const cd = ev.clipboardData;
|
|
210
|
+
if (!cd) return;
|
|
211
|
+
// Only chunk pastes aimed at the composer's editable surface —
|
|
212
|
+
// a paste into some unrelated input inside the scope is left alone.
|
|
213
|
+
const node = ev.target;
|
|
214
|
+
const el =
|
|
215
|
+
node instanceof HTMLElement ? node : null;
|
|
216
|
+
const isEditable =
|
|
217
|
+
el != null &&
|
|
218
|
+
(el instanceof HTMLTextAreaElement ||
|
|
219
|
+
el instanceof HTMLInputElement ||
|
|
220
|
+
el.isContentEditable);
|
|
221
|
+
if (!isEditable) return;
|
|
222
|
+
// A file/blob paste is the image path's job — don't steal it.
|
|
223
|
+
if (Array.from(cd.items).some((i) => i.kind === 'file')) return;
|
|
224
|
+
const text = cd.getData('text/plain');
|
|
225
|
+
if (text.length < pasteTextThreshold) return;
|
|
226
|
+
// Respect the file-count cap (text chunks share the attachment list).
|
|
227
|
+
const c = composerRef.current;
|
|
228
|
+
if (maxFiles != null && c.attachments.length >= maxFiles) return;
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
c.addAttachment(textToAttachment(text));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
target.addEventListener('paste', onPaste);
|
|
234
|
+
return () => target.removeEventListener('paste', onPaste);
|
|
235
|
+
}, [pasteTextEnabled, pasteTextThreshold, maxFiles, pasteScopeRef]);
|
|
236
|
+
|
|
190
237
|
// On unmount, free any object-URLs still held by staged attachments
|
|
191
238
|
// (drafts abandoned without sending). `composerRef` gives the latest
|
|
192
239
|
// list without re-running the effect on every attach.
|
|
@@ -44,8 +44,15 @@ export const LIMITS = {
|
|
|
44
44
|
attachmentsMax: 10,
|
|
45
45
|
/** Composer history slots. */
|
|
46
46
|
composerHistorySize: 50,
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Coalesce stream tokens within this window before dispatching one
|
|
49
|
+
* aggregated chunk. 33ms ≈ 30fps — enough to feel live while halving
|
|
50
|
+
* the re-parse count now that markdown renders during streaming
|
|
51
|
+
* (block-split + per-block memoization keeps each render ~O(n), but
|
|
52
|
+
* fewer dispatches still means fewer tail re-parses). ai-sdk uses ~50ms
|
|
53
|
+
* for the same reason.
|
|
54
|
+
*/
|
|
55
|
+
streamCoalesceMs: 33,
|
|
49
56
|
/** Default history page size. */
|
|
50
57
|
pageSize: 50,
|
|
51
58
|
/** Virtualize list when >= this many messages (host-controlled threshold). */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the FIRST previewable http(s) URL from a message's plain text.
|
|
3
|
+
*
|
|
4
|
+
* Telegram/WhatsApp rule: at most one preview per message — we surface the
|
|
5
|
+
* first link only. Returns `null` when there's nothing to unfurl.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately conservative: only absolute http/https URLs (no bare
|
|
8
|
+
* `example.com`, no mailto/ftp), trailing punctuation trimmed, and a length
|
|
9
|
+
* cap to avoid pathological inputs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// http(s) URL up to the first whitespace or closing bracket/quote.
|
|
13
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]}]+/i;
|
|
14
|
+
|
|
15
|
+
/** Trailing punctuation that's almost always sentence/markup, not the URL. */
|
|
16
|
+
const TRAILING = /[.,;:!?)\]}'"»]+$/;
|
|
17
|
+
|
|
18
|
+
export function extractFirstUrl(text: string | undefined | null): string | null {
|
|
19
|
+
if (!text) return null;
|
|
20
|
+
// Skip fenced/inline code-heavy content cheaply: still match, but the cap
|
|
21
|
+
// below guards huge blobs.
|
|
22
|
+
if (text.length > 50_000) return null;
|
|
23
|
+
|
|
24
|
+
const match = URL_RE.exec(text);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
|
|
27
|
+
let url = match[0].replace(TRAILING, '');
|
|
28
|
+
|
|
29
|
+
// Balance a trailing ")" that belongs to a markdown link `](url)` wrapper:
|
|
30
|
+
// if the URL has more ")" than "(", drop the extras.
|
|
31
|
+
const opens = (url.match(/\(/g) ?? []).length;
|
|
32
|
+
const closes = (url.match(/\)/g) ?? []).length;
|
|
33
|
+
if (closes > opens) {
|
|
34
|
+
url = url.replace(/\)+$/, (m) => m.slice(0, Math.max(0, m.length - (closes - opens))));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Validate — a malformed match resolves to no preview.
|
|
38
|
+
try {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
41
|
+
return parsed.toString();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -8,13 +8,23 @@
|
|
|
8
8
|
* - `version` bumps on edit so memo keys invalidate.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { ChatAttachment, ChatMessage,
|
|
11
|
+
import type { ChatAttachment, ChatMessage, ChatToolCall } from '../types';
|
|
12
12
|
|
|
13
13
|
export interface ChatState {
|
|
14
14
|
sessionId: string | null;
|
|
15
15
|
messages: ChatMessage[];
|
|
16
16
|
/** Initial history load in flight. */
|
|
17
17
|
isLoading: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* True once the initial history load has settled (done OR errored). False
|
|
20
|
+
* from mount until then. Lets the UI distinguish "history still loading"
|
|
21
|
+
* from "genuinely empty conversation": render a spinner while
|
|
22
|
+
* `!historyLoaded`, the empty-state only once `historyLoaded` is true and
|
|
23
|
+
* `messages` is empty. `isLoading` alone can't carry this — it is `false`
|
|
24
|
+
* on the very first synchronous render (before the bootstrap effect runs),
|
|
25
|
+
* which would flash the empty-state for a session that does have history.
|
|
26
|
+
*/
|
|
27
|
+
historyLoaded: boolean;
|
|
18
28
|
/** Assistant is generating a reply. */
|
|
19
29
|
isStreaming: boolean;
|
|
20
30
|
/** Older history page in flight. */
|
|
@@ -28,6 +38,7 @@ export const initialState: ChatState = {
|
|
|
28
38
|
sessionId: null,
|
|
29
39
|
messages: [],
|
|
30
40
|
isLoading: false,
|
|
41
|
+
historyLoaded: false,
|
|
31
42
|
isStreaming: false,
|
|
32
43
|
isLoadingMore: false,
|
|
33
44
|
hasMore: true,
|
|
@@ -73,9 +84,12 @@ export type ChatAction =
|
|
|
73
84
|
| {
|
|
74
85
|
type: 'STREAM_DONE';
|
|
75
86
|
id: string;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Generic finalize merge (content/blocks/sources/tokens/attachments/
|
|
89
|
+
* metadata). The single slot the terminal event carries — applied as
|
|
90
|
+
* a `Partial<ChatMessage>` merge. No-op when absent.
|
|
91
|
+
*/
|
|
92
|
+
patch?: Partial<ChatMessage>;
|
|
79
93
|
}
|
|
80
94
|
| { type: 'STREAM_CANCELLED'; id: string; partialText: string; label?: string }
|
|
81
95
|
| { type: 'STREAM_ERROR'; id?: string; message: string }
|
|
@@ -134,6 +148,11 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
134
148
|
messages: action.messages ?? state.messages,
|
|
135
149
|
hasMore: action.hasMore ?? state.hasMore,
|
|
136
150
|
oldestCursor: action.cursor ?? state.oldestCursor,
|
|
151
|
+
// When the session carries its messages (the autoCreateSession /
|
|
152
|
+
// newSession path commits them here), the initial load is settled.
|
|
153
|
+
// A bare `SESSION_SET` (resume path sets the id, then awaits
|
|
154
|
+
// loadHistory → HISTORY_LOAD_DONE) leaves `historyLoaded` untouched.
|
|
155
|
+
historyLoaded: action.messages !== undefined ? true : state.historyLoaded,
|
|
137
156
|
error: null,
|
|
138
157
|
};
|
|
139
158
|
|
|
@@ -144,6 +163,7 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
144
163
|
return {
|
|
145
164
|
...state,
|
|
146
165
|
isLoading: false,
|
|
166
|
+
historyLoaded: true,
|
|
147
167
|
messages: action.messages,
|
|
148
168
|
hasMore: action.hasMore,
|
|
149
169
|
oldestCursor: action.cursor,
|
|
@@ -258,10 +278,11 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
258
278
|
case 'STREAM_DONE': {
|
|
259
279
|
const patched = patchMessageById(state.messages, action.id, (m) => ({
|
|
260
280
|
...m,
|
|
281
|
+
// Generic finalize merge (content/blocks/sources/tokens/attachments/
|
|
282
|
+
// metadata). No-op when `patch` is absent — `message_end` is then a
|
|
283
|
+
// pure terminal that only clears `isStreaming`.
|
|
284
|
+
...(action.patch ?? {}),
|
|
261
285
|
isStreaming: false,
|
|
262
|
-
tokensIn: action.tokensIn ?? m.tokensIn,
|
|
263
|
-
tokensOut: action.tokensOut ?? m.tokensOut,
|
|
264
|
-
sources: action.sources ?? m.sources,
|
|
265
286
|
}));
|
|
266
287
|
// Drop an assistant message that ended up completely empty (no text, no
|
|
267
288
|
// tool calls). This happens in the HITL approval flow: the first stream
|
|
@@ -377,7 +398,10 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
|
|
|
377
398
|
};
|
|
378
399
|
|
|
379
400
|
case 'ERROR_SET':
|
|
380
|
-
|
|
401
|
+
// An error settles the initial load — a failed bootstrap must not leave
|
|
402
|
+
// a stuck spinner; clear `isLoading` and mark the load resolved so the
|
|
403
|
+
// host can fall through to its error/empty surface.
|
|
404
|
+
return { ...state, error: action.error, isLoading: false, historyLoaded: true };
|
|
381
405
|
|
|
382
406
|
case 'ATTACHMENT_PROGRESS': {
|
|
383
407
|
const messages = patchMessageById(state.messages, action.messageId, (m) => {
|
|
@@ -93,7 +93,10 @@ export function createMockTransport(opts: MockTransportOptions = {}): ChatTransp
|
|
|
93
93
|
await sleep(latency);
|
|
94
94
|
yield { type: 'chunk', delta: piece };
|
|
95
95
|
}
|
|
96
|
-
yield {
|
|
96
|
+
yield {
|
|
97
|
+
type: 'message_end',
|
|
98
|
+
patch: { tokensIn: content.length, tokensOut: reply.length },
|
|
99
|
+
};
|
|
97
100
|
} else {
|
|
98
101
|
for (const ev of reply) {
|
|
99
102
|
if (options.signal.aborted) return;
|
|
@@ -143,30 +143,34 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
143
143
|
const t0 = performance.now();
|
|
144
144
|
try {
|
|
145
145
|
if (config.initialSessionId) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
151
|
-
}
|
|
146
|
+
dispatch({
|
|
147
|
+
type: 'SESSION_SET',
|
|
148
|
+
sessionId: config.initialSessionId,
|
|
149
|
+
});
|
|
152
150
|
const page = await transport.loadHistory(config.initialSessionId, null, pageSize);
|
|
153
|
-
if
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
// Commit the loaded history even if THIS effect run was cancelled.
|
|
152
|
+
// Under React StrictMode the first mount's effect is always
|
|
153
|
+
// cancelled (mount → unmount → mount), and the second run skips the
|
|
154
|
+
// fetch because `sessionId` is already in the persisted reducer
|
|
155
|
+
// state (the early-return at the top of the effect). If we bailed
|
|
156
|
+
// here on `cancelled`, `HISTORY_LOAD_DONE` would never dispatch on
|
|
157
|
+
// either run and the resumed history would silently never reach the
|
|
158
|
+
// reducer — the read succeeds but the messages are dropped, so the
|
|
159
|
+
// chat shows the empty-state until a remount re-bootstraps it. The
|
|
160
|
+
// reducer state survives the StrictMode remount, so dispatching the
|
|
161
|
+
// load result regardless is correct (mirrors the autoCreateSession
|
|
162
|
+
// branch below, which already commits post-cancel for the same reason).
|
|
160
163
|
dispatch({
|
|
161
164
|
type: 'HISTORY_LOAD_DONE',
|
|
162
165
|
messages: page.messages,
|
|
163
166
|
hasMore: page.hasMore,
|
|
164
167
|
cursor: page.nextCursor,
|
|
165
168
|
});
|
|
166
|
-
log.bootstrap.success('resumed', {
|
|
169
|
+
log.bootstrap.success(cancelled ? 'resumed (post-cancel)' : 'resumed', {
|
|
167
170
|
sessionId: config.initialSessionId,
|
|
168
171
|
messages: page.messages.length,
|
|
169
172
|
hasMore: page.hasMore,
|
|
173
|
+
cancelled,
|
|
170
174
|
elapsedMs: Math.round(performance.now() - t0),
|
|
171
175
|
});
|
|
172
176
|
return config.initialSessionId;
|
|
@@ -397,14 +401,10 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
397
401
|
dispatch({
|
|
398
402
|
type: 'STREAM_DONE',
|
|
399
403
|
id: targetId,
|
|
400
|
-
|
|
401
|
-
tokensOut: ev.tokensOut,
|
|
402
|
-
sources: ev.sources,
|
|
404
|
+
patch: ev.patch,
|
|
403
405
|
});
|
|
404
406
|
log.stream.debug('message_end', {
|
|
405
|
-
|
|
406
|
-
tokensOut: ev.tokensOut,
|
|
407
|
-
sources: ev.sources?.length ?? 0,
|
|
407
|
+
patchKeys: ev.patch ? Object.keys(ev.patch) : [],
|
|
408
408
|
});
|
|
409
409
|
return;
|
|
410
410
|
case 'message_metrics':
|
package/src/tools/chat/lazy.tsx
CHANGED
|
@@ -75,6 +75,16 @@ export * from './composer';
|
|
|
75
75
|
export * from './shell';
|
|
76
76
|
export * from './launcher';
|
|
77
77
|
|
|
78
|
+
// Markdown renderer + plain-text helper — also surfaced from index.ts; the
|
|
79
|
+
// `./chat` package entry resolves to THIS file (lazy.tsx), so a host that
|
|
80
|
+
// composes a custom mention-aware bubble needs them re-exported here too
|
|
81
|
+
// (otherwise the symbols typecheck via index.ts but fail to bundle at runtime).
|
|
82
|
+
export {
|
|
83
|
+
MarkdownMessage,
|
|
84
|
+
extractTextFromChildren,
|
|
85
|
+
type MarkdownMessageProps,
|
|
86
|
+
} from '../dev/code/MarkdownMessage';
|
|
87
|
+
|
|
78
88
|
// Bridge — AI-driven `point` directives (lightweight, no heavy deps).
|
|
79
89
|
export {
|
|
80
90
|
HighlightOverlay,
|