@djangocfg/ui-tools 2.1.407 → 2.1.409
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/README.md +9 -10
- package/dist/file-icon/index.cjs +449 -61
- package/dist/file-icon/index.cjs.map +1 -1
- package/dist/file-icon/index.d.cts +56 -18
- package/dist/file-icon/index.d.ts +56 -18
- package/dist/file-icon/index.mjs +448 -62
- package/dist/file-icon/index.mjs.map +1 -1
- package/dist/tree/index.cjs +49 -22
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +9 -3
- package/dist/tree/index.d.ts +9 -3
- package/dist/tree/index.mjs +49 -22
- package/dist/tree/index.mjs.map +1 -1
- package/dist/{types-B_zhyAqR.d.cts → types-eEu8SeiQ.d.cts} +4 -0
- package/dist/{types-B_zhyAqR.d.ts → types-eEu8SeiQ.d.ts} +4 -0
- package/package.json +8 -13
- package/src/components/FloatingToolbar/index.tsx +37 -3
- package/src/lib/page-snapshot/__tests__/capture-integration.test.ts +85 -0
- package/src/lib/page-snapshot/__tests__/engine.test.ts +36 -0
- package/src/lib/page-snapshot/__tests__/redaction-integration.test.ts +99 -0
- package/src/lib/page-snapshot/__tests__/tokens.test.ts +17 -0
- package/src/lib/page-snapshot/capture/__tests__/budget.test.ts +49 -0
- package/src/lib/page-snapshot/capture/__tests__/chrome-filter.test.ts +47 -0
- package/src/lib/page-snapshot/capture/__tests__/fold.test.ts +66 -0
- package/src/lib/page-snapshot/capture/__tests__/scope.test.ts +74 -0
- package/src/lib/page-snapshot/capture/__tests__/walk.test.ts +129 -0
- package/src/lib/page-snapshot/capture/accessible-name.ts +73 -0
- package/src/lib/page-snapshot/capture/budget.ts +95 -0
- package/src/lib/page-snapshot/capture/chrome-filter.ts +81 -0
- package/src/lib/page-snapshot/capture/classify.ts +111 -0
- package/src/lib/page-snapshot/capture/dom-utils.ts +111 -0
- package/src/lib/page-snapshot/capture/fold.ts +96 -0
- package/src/lib/page-snapshot/capture/scope.ts +169 -0
- package/src/lib/page-snapshot/capture/walk.ts +250 -0
- package/src/lib/page-snapshot/cst/__tests__/serialize.test.ts +50 -0
- package/src/lib/page-snapshot/cst/directives.ts +47 -0
- package/src/lib/page-snapshot/cst/payload.ts +50 -0
- package/src/lib/page-snapshot/cst/serialize.ts +84 -0
- package/src/lib/page-snapshot/cst/types.ts +115 -0
- package/src/lib/page-snapshot/engine.ts +176 -0
- package/src/lib/page-snapshot/index.ts +93 -0
- package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +72 -0
- package/src/lib/page-snapshot/react/PageSnapshotPreview.tsx +78 -0
- package/src/lib/page-snapshot/react/__tests__/PageSnapshotChip.test.tsx +54 -0
- package/src/lib/page-snapshot/react/__tests__/provider.test.tsx +103 -0
- package/src/lib/page-snapshot/react/__tests__/use-page-snapshot-toggle.test.tsx +62 -0
- package/src/lib/page-snapshot/react/provider.tsx +162 -0
- package/src/lib/page-snapshot/react/use-page-snapshot-toggle.ts +47 -0
- package/src/lib/page-snapshot/react/use-page-snapshot.ts +67 -0
- package/src/lib/page-snapshot/redaction/__tests__/audit.test.ts +25 -0
- package/src/lib/page-snapshot/redaction/__tests__/heuristics.test.ts +73 -0
- package/src/lib/page-snapshot/redaction/__tests__/luhn.test.ts +26 -0
- package/src/lib/page-snapshot/redaction/__tests__/patterns.test.ts +60 -0
- package/src/lib/page-snapshot/redaction/audit.ts +58 -0
- package/src/lib/page-snapshot/redaction/heuristics.ts +75 -0
- package/src/lib/page-snapshot/redaction/index.ts +75 -0
- package/src/lib/page-snapshot/redaction/luhn.ts +25 -0
- package/src/lib/page-snapshot/redaction/patterns.ts +111 -0
- package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +24 -0
- package/src/lib/page-snapshot/refs/registry.ts +46 -0
- package/src/lib/page-snapshot/staleness/__tests__/hash.test.ts +34 -0
- package/src/lib/page-snapshot/staleness/hash.ts +20 -0
- package/src/lib/page-snapshot/tokens.ts +15 -0
- package/src/tools/AudioPlayer/context/PlayerProvider.tsx +13 -14
- package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +55 -6
- package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +2 -5
- package/src/tools/Chat/README.md +277 -39
- package/src/tools/Chat/composer/Composer.tsx +471 -0
- package/src/tools/Chat/composer/ComposerActionBar.tsx +65 -0
- package/src/tools/Chat/composer/ComposerBanner.tsx +128 -0
- package/src/tools/Chat/composer/ComposerButton.tsx +64 -0
- package/src/tools/Chat/composer/ComposerFooter.tsx +90 -0
- package/src/tools/Chat/composer/ComposerMenuButton.tsx +62 -0
- package/src/tools/Chat/composer/ComposerModelPicker.tsx +104 -0
- package/src/tools/Chat/composer/ComposerRichTextarea.tsx +88 -0
- package/src/tools/Chat/composer/ComposerToolPill.tsx +95 -0
- package/src/tools/Chat/composer/index.ts +45 -0
- package/src/tools/Chat/composer/size-context.tsx +26 -0
- package/src/tools/Chat/composer/types.ts +143 -0
- package/src/tools/Chat/composer/useComposerActions.tsx +164 -0
- package/src/tools/Chat/context/ChatProvider.tsx +54 -3
- package/src/tools/Chat/core/__tests__/metadata.test.ts +69 -0
- package/src/tools/Chat/core/index.ts +23 -1
- package/src/tools/Chat/core/markdown.ts +1 -1
- package/src/tools/Chat/core/metadata.ts +47 -0
- package/src/tools/Chat/core/payload-dispatch.ts +1 -1
- package/src/tools/Chat/core/transport/http.ts +71 -32
- package/src/tools/Chat/core/transport/sse.ts +18 -10
- package/src/tools/Chat/highlight/HighlightOverlay.tsx +101 -0
- package/src/tools/Chat/highlight/README.md +103 -0
- package/src/tools/Chat/highlight/SpotlightCanvas.tsx +153 -0
- package/src/tools/Chat/highlight/__tests__/HighlightOverlay.test.tsx +112 -0
- package/src/tools/Chat/highlight/__tests__/resolveRef.test.ts +55 -0
- package/src/tools/Chat/highlight/index.ts +21 -0
- package/src/tools/Chat/highlight/resolveRef.ts +42 -0
- package/src/tools/Chat/highlight/types.ts +49 -0
- package/src/tools/Chat/highlight/useHighlightTargets.ts +128 -0
- package/src/tools/Chat/hooks/index.ts +0 -5
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +28 -47
- package/src/tools/Chat/hooks/useChat.ts +47 -14
- package/src/tools/Chat/hooks/useChatComposer.ts +2 -2
- package/src/tools/Chat/hooks/useChatLayout.ts +1 -1
- package/src/tools/Chat/hooks/useStreamEndFocus.ts +54 -0
- package/src/tools/Chat/index.ts +25 -219
- package/src/tools/Chat/launcher/ChatDock.tsx +1 -1
- package/src/tools/Chat/launcher/ChatLauncher.tsx +1 -1
- package/src/tools/Chat/launcher/{ChatHeader.tsx → header/ChatHeader.tsx} +24 -11
- package/src/tools/Chat/launcher/{ChatHeaderActionButton.tsx → header/ChatHeaderActionButton.tsx} +34 -3
- package/src/tools/Chat/launcher/{ChatHeaderLanguageButton.tsx → header/ChatHeaderLanguageButton.tsx} +2 -2
- package/src/tools/Chat/launcher/{ChatHeaderModeToggle.tsx → header/ChatHeaderModeToggle.tsx} +1 -1
- package/src/tools/Chat/launcher/{ChatHeaderResetButton.tsx → header/ChatHeaderResetButton.tsx} +2 -1
- package/src/tools/Chat/launcher/{HeaderSlots.tsx → header/HeaderSlots.tsx} +3 -3
- package/src/tools/Chat/launcher/header/index.ts +26 -0
- package/src/tools/Chat/launcher/index.ts +3 -10
- package/src/tools/Chat/lazy.tsx +38 -284
- package/src/tools/Chat/{components → messages}/MessageBubble.tsx +58 -5
- package/src/tools/Chat/{components → messages}/MessageList.tsx +8 -25
- package/src/tools/Chat/messages/blocks/MessageBlocks.tsx +131 -0
- package/src/tools/Chat/messages/blocks/builtin.tsx +91 -0
- package/src/tools/Chat/messages/blocks/index.ts +12 -0
- package/src/tools/Chat/messages/blocks/registry.tsx +42 -0
- package/src/tools/Chat/messages/blocks/renderers/AudioBlock.tsx +20 -0
- package/src/tools/Chat/messages/blocks/renderers/CodeBlock.tsx +19 -0
- package/src/tools/Chat/messages/blocks/renderers/GalleryBlock.tsx +26 -0
- package/src/tools/Chat/messages/blocks/renderers/ImageBlock.tsx +27 -0
- package/src/tools/Chat/messages/blocks/renderers/JsonBlock.tsx +12 -0
- package/src/tools/Chat/messages/blocks/renderers/LottieBlock.tsx +11 -0
- package/src/tools/Chat/messages/blocks/renderers/MapBlock.tsx +36 -0
- package/src/tools/Chat/messages/blocks/renderers/MermaidBlock.tsx +11 -0
- package/src/tools/Chat/messages/blocks/renderers/VideoBlock.tsx +24 -0
- package/src/tools/Chat/messages/blocks/renderers/types.ts +8 -0
- package/src/tools/Chat/{components → messages}/index.ts +11 -5
- package/src/tools/Chat/public.ts +212 -0
- package/src/tools/Chat/shell/ChatRoot.tsx +345 -0
- package/src/tools/Chat/{components → shell}/EmptyState.tsx +4 -2
- package/src/tools/Chat/shell/index.ts +15 -0
- package/src/tools/Chat/types/block.ts +120 -0
- package/src/tools/Chat/types/config.ts +0 -5
- package/src/tools/Chat/types/index.ts +17 -0
- package/src/tools/Chat/types/message.ts +3 -0
- package/src/tools/Chat/utils/index.ts +4 -0
- package/src/tools/CodeEditor/README.md +4 -6
- package/src/tools/CodeEditor/components/DiffEditor.tsx +48 -13
- package/src/tools/CodeEditor/components/Editor.tsx +96 -44
- package/src/tools/CodeEditor/context/EditorProvider.tsx +34 -17
- package/src/tools/CodeEditor/hooks/useEditorTheme.ts +92 -99
- package/src/tools/CodeEditor/hooks/useMonaco.ts +37 -22
- package/src/tools/CodeEditor/lazy.tsx +6 -0
- package/src/tools/CodeEditor/lib/index.ts +1 -1
- package/src/tools/CodeEditor/lib/themes.ts +3 -39
- package/src/tools/CronScheduler/CronScheduler.client.tsx +230 -61
- package/src/tools/CronScheduler/components/CustomInput.tsx +21 -4
- package/src/tools/CronScheduler/components/DayChips.tsx +13 -11
- package/src/tools/CronScheduler/components/MonthDayGrid.tsx +4 -4
- package/src/tools/CronScheduler/components/SchedulePreview.tsx +7 -3
- package/src/tools/CronScheduler/components/TimeSelector.tsx +1 -1
- package/src/tools/CronScheduler/index.tsx +1 -1
- package/src/tools/CronScheduler/types/index.ts +8 -3
- package/src/tools/CronScheduler/utils/cron-humanize.ts +61 -16
- package/src/tools/CronScheduler/utils/cron-parser.ts +13 -4
- package/src/tools/FileIcon/FileIcon.tsx +24 -39
- package/src/tools/FileIcon/get-file-icon.ts +73 -0
- package/src/tools/FileIcon/icons/icon-data.ts +399 -0
- package/src/tools/FileIcon/index.ts +4 -0
- package/src/tools/FileIcon/loader.ts +17 -35
- package/src/tools/FileIcon/specialFolders.ts +18 -0
- package/src/tools/Gallery/components/lightbox/GalleryLightbox.tsx +112 -35
- package/src/tools/Gallery/components/media/GalleryVideo.tsx +21 -2
- package/src/tools/Gallery/components/preview/GalleryCarousel.tsx +11 -1
- package/src/tools/Gallery/hooks/usePreloadImages.ts +54 -7
- package/src/tools/ImageViewer/components/ImageInfo.tsx +12 -1
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +51 -43
- package/src/tools/ImageViewer/components/ImageViewer.tsx +96 -24
- package/src/tools/ImageViewer/hooks/useImageLoading.ts +13 -0
- package/src/tools/ImageViewer/utils/constants.ts +3 -0
- package/src/tools/ImageViewer/utils/index.ts +1 -0
- package/src/tools/JsonForm/JsonSchemaForm.tsx +4 -1
- package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +5 -3
- package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +7 -4
- package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +3 -1
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +23 -3
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +20 -12
- package/src/tools/JsonForm/widgets/NumberWidget.tsx +14 -9
- package/src/tools/JsonForm/widgets/RadioWidget.tsx +78 -0
- package/src/tools/JsonForm/widgets/SelectWidget.tsx +1 -0
- package/src/tools/JsonForm/widgets/SliderWidget.tsx +7 -4
- package/src/tools/JsonForm/widgets/TextWidget.tsx +41 -17
- package/src/tools/JsonForm/widgets/index.ts +1 -0
- package/src/tools/JsonTree/components/JsonContent.tsx +115 -40
- package/src/tools/LottiePlayer/LottiePlayer.client.tsx +177 -72
- package/src/tools/LottiePlayer/index.tsx +14 -4
- package/src/tools/LottiePlayer/lazy.tsx +11 -3
- package/src/tools/LottiePlayer/types.ts +31 -1
- package/src/tools/LottiePlayer/useLottie.ts +32 -9
- package/src/tools/LottiePlayer/usePrefersReducedMotion.ts +46 -0
- package/src/tools/Map/components/LayerSwitcher.tsx +54 -21
- package/src/tools/Map/components/MapCluster.tsx +28 -21
- package/src/tools/Map/components/MapContainer.tsx +11 -4
- package/src/tools/Map/components/MapLegend.tsx +46 -15
- package/src/tools/Map/components/MapMarker.tsx +31 -2
- package/src/tools/Map/hooks/useMapEvents.ts +64 -105
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +61 -6
- package/src/tools/MarkdownEditor/MentionList.tsx +37 -4
- package/src/tools/MarkdownEditor/createMentionSuggestion.ts +11 -0
- package/src/tools/MarkdownEditor/lazy.tsx +32 -7
- package/src/tools/MarkdownEditor/styles.css +13 -0
- package/src/tools/MarkdownMessage/CodeBlock.tsx +40 -17
- package/src/tools/MarkdownMessage/MarkdownMessage.tsx +26 -6
- package/src/tools/MarkdownMessage/components.tsx +22 -9
- package/src/tools/MarkdownMessage/types.ts +24 -1
- package/src/tools/Mermaid/Mermaid.client.tsx +27 -5
- package/src/tools/Mermaid/components/MermaidErrorPanel.tsx +31 -0
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +14 -17
- package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +264 -168
- package/src/tools/Mermaid/hooks/useMermaidValidation.ts +76 -10
- package/src/tools/Mermaid/index.tsx +6 -0
- package/src/tools/Mermaid/utils/mermaid-helpers.ts +141 -18
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +11 -1
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +49 -20
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +7 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +7 -4
- package/src/tools/OpenapiViewer/constants.ts +3 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +73 -11
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +26 -6
- package/src/tools/PrettyCode/PrettyCode.client.tsx +23 -16
- package/src/tools/PrettyCode/lazy.tsx +1 -1
- package/src/tools/SpeechRecognition/README.md +1 -1
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +9 -3
- package/src/tools/SpeechRecognition/components/RecordingPulse.tsx +59 -0
- package/src/tools/SpeechRecognition/components/index.ts +2 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +24 -7
- package/src/tools/SpeechRecognition/core/language.ts +23 -6
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +36 -5
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +18 -11
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +94 -26
- package/src/tools/SpeechRecognition/widgets/index.ts +1 -1
- package/src/tools/Tree/README.md +4 -8
- package/src/tools/Tree/TreeRoot.tsx +22 -10
- package/src/tools/Tree/components/TreeContent.tsx +24 -4
- package/src/tools/Tree/components/TreeLabel.tsx +8 -2
- package/src/tools/Tree/components/TreeRow.tsx +16 -6
- package/src/tools/Tree/data/flatten.ts +10 -4
- package/src/tools/Tree/types.ts +4 -0
- package/src/tools/Uploader/components/UploadAddButton.tsx +29 -6
- package/src/tools/Uploader/components/UploadDropzone.tsx +63 -7
- package/src/tools/Uploader/components/UploadPageDropOverlay.tsx +19 -5
- package/src/tools/Uploader/components/UploadPreviewItem.tsx +47 -17
- package/src/tools/Uploader/components/UploadPreviewList.tsx +24 -12
- package/src/tools/Uploader/utils/formatters.ts +8 -3
- package/src/tools/VideoPlayer/canvas/hls-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/canvas/{jsx.d.ts → jsx-augmentation.ts} +12 -19
- package/src/tools/VideoPlayer/canvas/vimeo-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/canvas/youtube-canvas.tsx +1 -0
- package/src/tools/VideoPlayer/parts/fullscreen.tsx +1 -1
- package/src/tools/VideoPlayer/parts/pip.tsx +1 -1
- package/src/tools/VideoPlayer/parts/playback-rate.tsx +1 -1
- package/src/tools/VideoPlayer/parts/seek-bar.tsx +2 -2
- package/src/tools/VideoPlayer/parts/volume.tsx +2 -2
- package/src/tools/index.ts +2 -1
- package/src/tools/Chat/components/AudioToggle.tsx +0 -78
- package/src/tools/Chat/components/ChatRoot.tsx +0 -305
- package/src/tools/Chat/components/Composer.tsx +0 -216
- package/src/tools/Chat/hooks/useChatScroll.ts +0 -145
- package/src/tools/Chat/types.ts +0 -9
- package/src/tools/JsonTree/components/JsonToolbar.tsx +0 -95
- package/src/tools/JsonTree/hooks/useElementCorner.ts +0 -84
- package/src/tools/JsonTree/hooks/useNavbarHeight.ts +0 -83
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +0 -121
- package/src/tools/Tour/README.md +0 -373
- package/src/tools/Tour/components/Tour.tsx +0 -12
- package/src/tools/Tour/components/TourContent.tsx +0 -171
- package/src/tools/Tour/components/TourNavigation.tsx +0 -77
- package/src/tools/Tour/components/TourProgress.tsx +0 -88
- package/src/tools/Tour/components/TourSpotlight.tsx +0 -199
- package/src/tools/Tour/components/index.ts +0 -5
- package/src/tools/Tour/context/TourContext.ts +0 -19
- package/src/tools/Tour/context/TourProvider.tsx +0 -292
- package/src/tools/Tour/context/index.ts +0 -2
- package/src/tools/Tour/hooks/index.ts +0 -3
- package/src/tools/Tour/hooks/useKeyboardNavigation.ts +0 -59
- package/src/tools/Tour/hooks/useStepTarget.ts +0 -121
- package/src/tools/Tour/hooks/useTour.ts +0 -42
- package/src/tools/Tour/index.ts +0 -38
- package/src/tools/Tour/types/index.ts +0 -224
- package/src/tools/Tour/utils/dom.ts +0 -98
- package/src/tools/Tour/utils/index.ts +0 -3
- package/src/tools/Tour/utils/logger.ts +0 -3
- package/src/tools/Tour/utils/scrollIntoView.ts +0 -24
- /package/src/tools/Chat/{config.ts → constants.ts} +0 -0
- /package/src/tools/Chat/launcher/{ChatHeaderAudioToggle.tsx → header/ChatHeaderAudioToggle.tsx} +0 -0
- /package/src/tools/Chat/{components → messages}/Attachments.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/JumpToLatest.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/MessageActions.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/Sources.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/StreamingIndicator.tsx +0 -0
- /package/src/tools/Chat/{components → messages}/ToolCalls.tsx +0 -0
- /package/src/tools/Chat/{components → shell}/ErrorBanner.tsx +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { resolveRefs, type RefResolver } from '../resolveRef';
|
|
5
|
+
import type { CSTRefId } from '../types';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
document.body.innerHTML = '';
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** A tiny resolver backed by a plain map, for tests. */
|
|
12
|
+
function makeResolver(map: Record<string, HTMLElement | null>): RefResolver {
|
|
13
|
+
return {
|
|
14
|
+
resolve: (ref: CSTRefId) => map[ref] ?? null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('resolveRefs', () => {
|
|
19
|
+
it('returns nothing without a resolver', () => {
|
|
20
|
+
expect(resolveRefs(['@e1'], null)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves known refs to connected elements', () => {
|
|
24
|
+
const el = document.createElement('button');
|
|
25
|
+
document.body.appendChild(el);
|
|
26
|
+
const resolver = makeResolver({ '@e1': el });
|
|
27
|
+
|
|
28
|
+
const out = resolveRefs(['@e1'], resolver);
|
|
29
|
+
expect(out).toHaveLength(1);
|
|
30
|
+
expect(out[0].ref).toBe('@e1');
|
|
31
|
+
expect(out[0].element).toBe(el);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('drops refs the resolver does not know', () => {
|
|
35
|
+
const resolver = makeResolver({});
|
|
36
|
+
expect(resolveRefs(['@e9'], resolver)).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('drops elements detached from the DOM', () => {
|
|
40
|
+
// Created but never appended — not connected.
|
|
41
|
+
const orphan = document.createElement('div');
|
|
42
|
+
const resolver = makeResolver({ '@e2': orphan });
|
|
43
|
+
expect(resolveRefs(['@e2'], resolver)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('resolves a batch, keeping only the live ones', () => {
|
|
47
|
+
const live = document.createElement('a');
|
|
48
|
+
document.body.appendChild(live);
|
|
49
|
+
const orphan = document.createElement('a');
|
|
50
|
+
const resolver = makeResolver({ '@e1': live, '@e2': orphan, '@e3': null });
|
|
51
|
+
|
|
52
|
+
const out = resolveRefs(['@e1', '@e2', '@e3'], resolver);
|
|
53
|
+
expect(out.map((o) => o.ref)).toEqual(['@e1']);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chat highlight — AI-driven `point` directives.
|
|
5
|
+
*
|
|
6
|
+
* When the assistant returns `point` directives, this module resolves
|
|
7
|
+
* the CST refs to live elements and draws a spotlight overlay over
|
|
8
|
+
* them (and optionally focuses one). Read-only: it never changes the
|
|
9
|
+
* user's data.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { HighlightOverlay, type HighlightOverlayProps } from './HighlightOverlay';
|
|
13
|
+
export { SpotlightCanvas, type SpotlightCanvasProps } from './SpotlightCanvas';
|
|
14
|
+
export { useHighlightTargets } from './useHighlightTargets';
|
|
15
|
+
export { resolveRefs, type RefResolver } from './resolveRef';
|
|
16
|
+
export type {
|
|
17
|
+
PointDirective,
|
|
18
|
+
HighlightTarget,
|
|
19
|
+
SpotlightRect,
|
|
20
|
+
CSTRefId,
|
|
21
|
+
} from './types';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a CST ref id to a live DOM element.
|
|
3
|
+
*
|
|
4
|
+
* The page-snapshot engine assigns each interactive element a ref
|
|
5
|
+
* (`@e4`) during capture and keeps a `ref → element` registry. When the
|
|
6
|
+
* AI returns a `point` directive citing a ref, the chat resolves it
|
|
7
|
+
* back through that registry.
|
|
8
|
+
*
|
|
9
|
+
* A ref that no longer resolves is "stale" — the element was removed or
|
|
10
|
+
* the user navigated away. Callers treat a null result as stale.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CSTRefId } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal shape of a snapshot ref registry — `resolve(ref)` returns the
|
|
17
|
+
* element or null. Structurally compatible with the page-snapshot
|
|
18
|
+
* engine's `RefRegistry` without importing it (keeps the highlight
|
|
19
|
+
* module decoupled from the capture engine internals).
|
|
20
|
+
*/
|
|
21
|
+
export interface RefResolver {
|
|
22
|
+
resolve(ref: CSTRefId): HTMLElement | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a batch of refs to elements, dropping the ones that no longer
|
|
27
|
+
* exist. Returns pairs so the caller keeps the ref↔element association.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveRefs(
|
|
30
|
+
refs: CSTRefId[],
|
|
31
|
+
resolver: RefResolver | null,
|
|
32
|
+
): Array<{ ref: CSTRefId; element: HTMLElement }> {
|
|
33
|
+
if (!resolver) return [];
|
|
34
|
+
const out: Array<{ ref: CSTRefId; element: HTMLElement }> = [];
|
|
35
|
+
for (const ref of refs) {
|
|
36
|
+
const element = resolver.resolve(ref);
|
|
37
|
+
if (element && element.isConnected) {
|
|
38
|
+
out.push({ ref, element });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the AI-driven highlight overlay.
|
|
3
|
+
*
|
|
4
|
+
* The assistant points at the page by CST ref id; the chat resolves
|
|
5
|
+
* each ref to a live element and this overlay draws a spotlight on it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A CST ref id, e.g. "@e4". */
|
|
9
|
+
export type CSTRefId = `@e${number}`;
|
|
10
|
+
|
|
11
|
+
/** A `point` directive as received from the backend SSE stream. */
|
|
12
|
+
export interface PointDirective {
|
|
13
|
+
type: 'point';
|
|
14
|
+
/** CST ref of the target element. */
|
|
15
|
+
ref: CSTRefId;
|
|
16
|
+
/** Draw the highlight overlay (default: true). */
|
|
17
|
+
highlight?: boolean;
|
|
18
|
+
/** Move focus into the element once visible (default: false). */
|
|
19
|
+
focus?: boolean;
|
|
20
|
+
/** Optional short caption shown beside the highlight. */
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One resolved highlight target — a directive whose ref was found in
|
|
26
|
+
* the live DOM, paired with its element and measured rect.
|
|
27
|
+
*/
|
|
28
|
+
export interface HighlightTarget {
|
|
29
|
+
/** The resolved DOM element. */
|
|
30
|
+
element: HTMLElement;
|
|
31
|
+
/** Measured bounding rect (viewport coords). */
|
|
32
|
+
rect: DOMRect;
|
|
33
|
+
/** Caption to render beside the highlight. */
|
|
34
|
+
label?: string;
|
|
35
|
+
/** Whether to move focus into the element. */
|
|
36
|
+
focus: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A geometry-only target the SVG renderer consumes — decoupled from the
|
|
41
|
+
* DOM element so the renderer stays a pure presentational component.
|
|
42
|
+
*/
|
|
43
|
+
export interface SpotlightRect {
|
|
44
|
+
rect: DOMRect;
|
|
45
|
+
/** Extra px around the element. */
|
|
46
|
+
padding: number;
|
|
47
|
+
/** Corner radius. */
|
|
48
|
+
radius: number;
|
|
49
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { resolveRefs, type RefResolver } from './resolveRef';
|
|
6
|
+
import type { HighlightTarget, PointDirective } from './types';
|
|
7
|
+
|
|
8
|
+
/** Is a rect fully inside the viewport. */
|
|
9
|
+
function isOnScreen(rect: DOMRect): boolean {
|
|
10
|
+
return (
|
|
11
|
+
rect.top >= 0 &&
|
|
12
|
+
rect.left >= 0 &&
|
|
13
|
+
rect.bottom <= window.innerHeight &&
|
|
14
|
+
rect.right <= window.innerWidth
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `point` directives into live, geometry-tracked highlight
|
|
20
|
+
* targets.
|
|
21
|
+
*
|
|
22
|
+
* - resolves each ref to a DOM element (drops stale ones);
|
|
23
|
+
* - measures rects and keeps them fresh on scroll / resize;
|
|
24
|
+
* - scrolls the first off-screen target into view;
|
|
25
|
+
* - moves focus into a target when the directive asked for it;
|
|
26
|
+
* - drops a target automatically if its element leaves the DOM.
|
|
27
|
+
*
|
|
28
|
+
* One effect owns the whole lifecycle — resolve, side-effects, and the
|
|
29
|
+
* geometry subscription — so there is no ordering ambiguity between
|
|
30
|
+
* separate effects.
|
|
31
|
+
*/
|
|
32
|
+
export function useHighlightTargets(
|
|
33
|
+
directives: PointDirective[],
|
|
34
|
+
resolver: RefResolver | null,
|
|
35
|
+
): HighlightTarget[] {
|
|
36
|
+
const [targets, setTargets] = useState<HighlightTarget[]>([]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// Resolve every directive that wants a highlight to a live element.
|
|
40
|
+
const wanted = directives.filter((d) => d.highlight !== false);
|
|
41
|
+
const resolved = resolveRefs(
|
|
42
|
+
wanted.map((d) => d.ref),
|
|
43
|
+
resolver,
|
|
44
|
+
);
|
|
45
|
+
const pairs = resolved
|
|
46
|
+
.map(({ ref, element }) => {
|
|
47
|
+
const directive = wanted.find((d) => d.ref === ref);
|
|
48
|
+
return directive ? { element, directive } : null;
|
|
49
|
+
})
|
|
50
|
+
.filter(
|
|
51
|
+
(p): p is { element: HTMLElement; directive: PointDirective } =>
|
|
52
|
+
p !== null,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (pairs.length === 0) {
|
|
56
|
+
setTargets([]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Measure all targets — called now and on every scroll / resize.
|
|
61
|
+
const measure = () => {
|
|
62
|
+
const next: HighlightTarget[] = [];
|
|
63
|
+
for (const { element, directive } of pairs) {
|
|
64
|
+
if (!element.isConnected) continue; // element left the DOM
|
|
65
|
+
next.push({
|
|
66
|
+
element,
|
|
67
|
+
rect: element.getBoundingClientRect(),
|
|
68
|
+
label: directive.label,
|
|
69
|
+
focus: directive.focus ?? false,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
setTargets(next);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let frame = 0;
|
|
76
|
+
const schedule = () => {
|
|
77
|
+
cancelAnimationFrame(frame);
|
|
78
|
+
frame = requestAnimationFrame(measure);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Scroll the first target into view if it is off-screen.
|
|
82
|
+
const first = pairs[0].element;
|
|
83
|
+
const firstOffScreen = !isOnScreen(first.getBoundingClientRect());
|
|
84
|
+
if (firstOffScreen) {
|
|
85
|
+
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Focus a target if requested — after any scroll settles.
|
|
89
|
+
const focusPair = pairs.find((p) => p.directive.focus);
|
|
90
|
+
let focusTimer = 0;
|
|
91
|
+
if (focusPair) {
|
|
92
|
+
const el = focusPair.element;
|
|
93
|
+
focusTimer = window.setTimeout(
|
|
94
|
+
() => {
|
|
95
|
+
if (!el.isConnected) return;
|
|
96
|
+
// Make a non-focusable element focusable so focus() lands.
|
|
97
|
+
const nativelyFocusable = [
|
|
98
|
+
'INPUT',
|
|
99
|
+
'TEXTAREA',
|
|
100
|
+
'SELECT',
|
|
101
|
+
'BUTTON',
|
|
102
|
+
'A',
|
|
103
|
+
].includes(el.tagName);
|
|
104
|
+
if (el.tabIndex < 0 && !nativelyFocusable) el.tabIndex = -1;
|
|
105
|
+
el.focus({ preventScroll: true });
|
|
106
|
+
},
|
|
107
|
+
firstOffScreen ? 400 : 0,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Initial measure + keep geometry fresh.
|
|
112
|
+
measure();
|
|
113
|
+
window.addEventListener('scroll', schedule, true);
|
|
114
|
+
window.addEventListener('resize', schedule);
|
|
115
|
+
const observer = new ResizeObserver(schedule);
|
|
116
|
+
for (const { element } of pairs) observer.observe(element);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
cancelAnimationFrame(frame);
|
|
120
|
+
window.clearTimeout(focusTimer);
|
|
121
|
+
window.removeEventListener('scroll', schedule, true);
|
|
122
|
+
window.removeEventListener('resize', schedule);
|
|
123
|
+
observer.disconnect();
|
|
124
|
+
};
|
|
125
|
+
}, [directives, resolver]);
|
|
126
|
+
|
|
127
|
+
return targets;
|
|
128
|
+
}
|
|
@@ -6,11 +6,6 @@ export {
|
|
|
6
6
|
type UseChatComposerOptions,
|
|
7
7
|
type UseChatComposerReturn,
|
|
8
8
|
} from './useChatComposer';
|
|
9
|
-
export {
|
|
10
|
-
useChatScroll,
|
|
11
|
-
type UseChatScrollOptions,
|
|
12
|
-
type UseChatScrollReturn,
|
|
13
|
-
} from './useChatScroll';
|
|
14
9
|
export { useChatHistory, type UseChatHistoryOptions } from './useChatHistory';
|
|
15
10
|
export {
|
|
16
11
|
useChatLayout,
|
|
@@ -3,13 +3,9 @@
|
|
|
3
3
|
import { type RefObject, useEffect, useRef } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useChatContextOptional, type ComposerHandle } from '../context';
|
|
6
|
+
import { useStreamEndFocus, type Focusable } from './useStreamEndFocus';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
* composer's textareaRef from `useChatComposer`, and any custom
|
|
9
|
-
* imperative handle exposing the same shape. */
|
|
10
|
-
export interface Focusable {
|
|
11
|
-
focus: () => void;
|
|
12
|
-
}
|
|
8
|
+
export type { Focusable } from './useStreamEndFocus';
|
|
13
9
|
|
|
14
10
|
export interface UseAutoFocusOnStreamEndOptions {
|
|
15
11
|
/** True while an assistant reply is streaming. The hook fires the
|
|
@@ -43,22 +39,21 @@ export interface UseAutoFocusOnStreamEndOptions {
|
|
|
43
39
|
* streaming. Standard chat UX: the user types → sends → reads the
|
|
44
40
|
* reply → starts typing again without reaching for the mouse.
|
|
45
41
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
42
|
+
* **You usually do NOT need to call this.** `<ChatProvider>` runs the
|
|
43
|
+
* stream-end focus internally, so every chat — `ChatRoot`, a hand-rolled
|
|
44
|
+
* `ChatProvider` + `Composer` layout, or a headless setup with a
|
|
45
|
+
* registered composer — gets it for free. Disable it provider-wide with
|
|
46
|
+
* `<ChatProvider autoFocusOnStreamEnd={false}>`.
|
|
50
47
|
*
|
|
51
|
-
*
|
|
48
|
+
* Call this hook directly only for advanced cases the provider can't
|
|
49
|
+
* cover:
|
|
52
50
|
*
|
|
51
|
+
* // focus something OTHER than the composer (an approve button):
|
|
53
52
|
* const ref = useRef<{ focus: () => void } | null>(null);
|
|
54
53
|
* useAutoFocusOnStreamEnd({ targetRef: ref });
|
|
55
54
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* // drive stream state from your own store:
|
|
58
56
|
* useAutoFocusOnStreamEnd({ isStreaming: myExternalStreaming });
|
|
59
|
-
*
|
|
60
|
-
* Only the true → false transition fires focus — toggling `enabled`
|
|
61
|
-
* mid-stream won't steal focus while the user is reading.
|
|
62
57
|
*/
|
|
63
58
|
export function useAutoFocusOnStreamEnd(
|
|
64
59
|
options: UseAutoFocusOnStreamEndOptions = {},
|
|
@@ -68,38 +63,19 @@ export function useAutoFocusOnStreamEnd(
|
|
|
68
63
|
// Prefer the prop (caller knows best), fall back to context.
|
|
69
64
|
const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
|
|
70
65
|
|
|
71
|
-
// Keep latest ctx-composer in a ref so
|
|
72
|
-
//
|
|
73
|
-
// composers re-mount.
|
|
66
|
+
// Keep latest ctx-composer in a ref so target resolution always sees
|
|
67
|
+
// the freshest registered handle.
|
|
74
68
|
const composerHandleRef = useRef<ComposerHandle | null>(null);
|
|
75
69
|
composerHandleRef.current = ctx?.composer ?? null;
|
|
76
70
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const focusNow = () => {
|
|
87
|
-
// Resolve target in priority order: explicit ref > registered
|
|
88
|
-
// composer handle. Refs may carry an HTMLElement (raw DOM) or
|
|
89
|
-
// any object with `.focus()`; both are handled by the same
|
|
90
|
-
// call site below.
|
|
91
|
-
const explicit = targetRef?.current as Focusable | null;
|
|
92
|
-
const target: Focusable | null = explicit ?? composerHandleRef.current;
|
|
93
|
-
target?.focus();
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
if (delayMs > 0) {
|
|
97
|
-
const id = window.setTimeout(focusNow, delayMs);
|
|
98
|
-
return () => window.clearTimeout(id);
|
|
99
|
-
}
|
|
100
|
-
const raf = requestAnimationFrame(focusNow);
|
|
101
|
-
return () => cancelAnimationFrame(raf);
|
|
102
|
-
}, [isStreaming, enabled, delayMs, targetRef]);
|
|
71
|
+
useStreamEndFocus({
|
|
72
|
+
isStreaming,
|
|
73
|
+
enabled,
|
|
74
|
+
delayMs,
|
|
75
|
+
// Resolve in priority order: explicit ref > registered composer.
|
|
76
|
+
resolveTarget: () =>
|
|
77
|
+
(targetRef?.current as Focusable | null) ?? composerHandleRef.current,
|
|
78
|
+
});
|
|
103
79
|
}
|
|
104
80
|
|
|
105
81
|
/**
|
|
@@ -122,9 +98,14 @@ export function useRegisterComposer(handle: ComposerHandle): void {
|
|
|
122
98
|
const register = ctx?.registerComposer;
|
|
123
99
|
const focus = handle.focus;
|
|
124
100
|
const moveCursorToEnd = handle.moveCursorToEnd;
|
|
101
|
+
// Forward `getValue/setValue` too — voice dictation reads/writes the
|
|
102
|
+
// draft through them, so dropping them silently broke dictation for
|
|
103
|
+
// custom composers (e.g. the TipTap MarkdownEditor wrapper).
|
|
104
|
+
const getValue = handle.getValue;
|
|
105
|
+
const setValue = handle.setValue;
|
|
125
106
|
useEffect(() => {
|
|
126
107
|
if (!register) return;
|
|
127
|
-
register({ focus, moveCursorToEnd });
|
|
108
|
+
register({ focus, moveCursorToEnd, getValue, setValue });
|
|
128
109
|
return () => register(null);
|
|
129
|
-
}, [register, focus, moveCursorToEnd]);
|
|
110
|
+
}, [register, focus, moveCursorToEnd, getValue, setValue]);
|
|
130
111
|
}
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
ChatTransport,
|
|
11
11
|
ChatToolCall,
|
|
12
12
|
} from '../types';
|
|
13
|
-
import { LIMITS } from '../
|
|
13
|
+
import { LIMITS } from '../constants';
|
|
14
14
|
import {
|
|
15
15
|
type ChatState,
|
|
16
16
|
initialState,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { createId } from '../core/ids';
|
|
21
21
|
import { getChatLogger } from '../core/logger';
|
|
22
22
|
import { createTokenBuffer } from '../core/markdown';
|
|
23
|
+
import { resolveSendMetadata } from '../core/metadata';
|
|
23
24
|
|
|
24
25
|
export interface UseChatConfig {
|
|
25
26
|
transport: ChatTransport;
|
|
@@ -47,6 +48,17 @@ export interface UseChatConfig {
|
|
|
47
48
|
* sees plain text, while the bubble keeps the chip rendering. Plan64.
|
|
48
49
|
*/
|
|
49
50
|
onBeforeSend?: (content: string) => string | Promise<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Contribute extra transport metadata, computed fresh at send time.
|
|
53
|
+
* Invoked synchronously right before each `transport.stream/send`,
|
|
54
|
+
* and the result is merged over the static `metadata`. Returning
|
|
55
|
+
* `undefined` adds nothing.
|
|
56
|
+
*
|
|
57
|
+
* Use case: the page-context snapshot — captured per message
|
|
58
|
+
* (capture-on-submit) and carried as a separate `metadata` field,
|
|
59
|
+
* never mixed into the message content.
|
|
60
|
+
*/
|
|
61
|
+
getDynamicMetadata?: () => Record<string, unknown> | undefined;
|
|
50
62
|
/**
|
|
51
63
|
* Enable verbose dev-mode logging (consola, namespace `chat:*`).
|
|
52
64
|
* Defaults to `isDev` from `@djangocfg/ui-core/lib`. Pass `false` to silence
|
|
@@ -228,11 +240,16 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
228
240
|
abortRef.current = ctrl;
|
|
229
241
|
const assistantId = createId('a');
|
|
230
242
|
streamingMsgIdRef.current = assistantId;
|
|
243
|
+
// The message id that subsequent events (chunks, tool calls, done)
|
|
244
|
+
// target. Starts as the freshly-created placeholder; on a
|
|
245
|
+
// `resume_start` it is repointed to the resumed assistant message
|
|
246
|
+
// so STREAM_DONE / TOOL_CALL_* don't land on a removed placeholder.
|
|
247
|
+
let targetId = assistantId;
|
|
231
248
|
|
|
232
249
|
const iterator = transport.stream(sessionId, content, {
|
|
233
250
|
signal: ctrl.signal,
|
|
234
251
|
attachments,
|
|
235
|
-
metadata: config.metadata,
|
|
252
|
+
metadata: resolveSendMetadata(config.metadata, config.getDynamicMetadata),
|
|
236
253
|
});
|
|
237
254
|
|
|
238
255
|
// Peek at the first event — if it's `resume_start` we reuse the last assistant
|
|
@@ -264,6 +281,19 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
264
281
|
// just-created empty placeholder and mark the last assistant msg streaming.
|
|
265
282
|
dispatch({ type: 'STREAM_CANCEL_PLACEHOLDER', id: assistantId });
|
|
266
283
|
dispatch({ type: 'STREAM_RESUME_EXISTING' });
|
|
284
|
+
// Repoint the event target at the resumed message. Without
|
|
285
|
+
// this, every subsequent chunk / tool call / message_end
|
|
286
|
+
// dispatches against the removed placeholder id and is lost
|
|
287
|
+
// — the resumed bubble would stream forever (isStreaming
|
|
288
|
+
// never cleared) and tool panels would never appear.
|
|
289
|
+
for (let i = stateRef.current.messages.length - 1; i >= 0; i -= 1) {
|
|
290
|
+
const m = stateRef.current.messages[i];
|
|
291
|
+
if (m.role === 'assistant' && m.isStreaming) {
|
|
292
|
+
targetId = m.id;
|
|
293
|
+
streamingMsgIdRef.current = m.id;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
267
297
|
} else {
|
|
268
298
|
peekedEvent = ev;
|
|
269
299
|
}
|
|
@@ -279,10 +309,10 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
279
309
|
|
|
280
310
|
// If transport never emitted message_end, finalize manually.
|
|
281
311
|
if (stateRef.current.isStreaming) {
|
|
282
|
-
dispatch({ type: 'STREAM_DONE', id:
|
|
312
|
+
dispatch({ type: 'STREAM_DONE', id: targetId });
|
|
283
313
|
}
|
|
284
314
|
|
|
285
|
-
const finalMsg = stateRef.current.messages.find((m) => m.id ===
|
|
315
|
+
const finalMsg = stateRef.current.messages.find((m) => m.id === targetId);
|
|
286
316
|
if (finalMsg) config.onMessageEnd?.(finalMsg);
|
|
287
317
|
log.stream.success('done', {
|
|
288
318
|
assistantId,
|
|
@@ -294,14 +324,14 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
294
324
|
tokenBuffer.close();
|
|
295
325
|
if (ctrl.signal.aborted) {
|
|
296
326
|
const partial =
|
|
297
|
-
stateRef.current.messages.find((m) => m.id ===
|
|
298
|
-
dispatch({ type: 'STREAM_CANCELLED', id:
|
|
299
|
-
log.stream.warn('cancelled', { assistantId, partialChars: partial.length });
|
|
327
|
+
stateRef.current.messages.find((m) => m.id === targetId)?.content ?? '';
|
|
328
|
+
dispatch({ type: 'STREAM_CANCELLED', id: targetId, partialText: partial });
|
|
329
|
+
log.stream.warn('cancelled', { assistantId: targetId, partialChars: partial.length });
|
|
300
330
|
return;
|
|
301
331
|
}
|
|
302
332
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
303
333
|
lastErrorRef.current = e;
|
|
304
|
-
dispatch({ type: 'STREAM_ERROR', id:
|
|
334
|
+
dispatch({ type: 'STREAM_ERROR', id: targetId, message: e.message });
|
|
305
335
|
config.onError?.(e);
|
|
306
336
|
log.error.error('stream failed', { assistantId, message: e.message });
|
|
307
337
|
} finally {
|
|
@@ -338,7 +368,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
338
368
|
};
|
|
339
369
|
dispatch({
|
|
340
370
|
type: 'TOOL_CALL_START',
|
|
341
|
-
messageId:
|
|
371
|
+
messageId: targetId,
|
|
342
372
|
toolCall,
|
|
343
373
|
});
|
|
344
374
|
log.tools.info('call_start', { toolId: ev.toolId, name: ev.name });
|
|
@@ -347,7 +377,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
347
377
|
case 'tool_call_delta':
|
|
348
378
|
dispatch({
|
|
349
379
|
type: 'TOOL_CALL_DELTA',
|
|
350
|
-
messageId:
|
|
380
|
+
messageId: targetId,
|
|
351
381
|
toolId: ev.toolId,
|
|
352
382
|
delta: ev.delta,
|
|
353
383
|
});
|
|
@@ -355,7 +385,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
355
385
|
case 'tool_call_end':
|
|
356
386
|
dispatch({
|
|
357
387
|
type: 'TOOL_CALL_END',
|
|
358
|
-
messageId:
|
|
388
|
+
messageId: targetId,
|
|
359
389
|
toolId: ev.toolId,
|
|
360
390
|
output: ev.output,
|
|
361
391
|
status: ev.status,
|
|
@@ -366,7 +396,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
366
396
|
tokenBuffer.flush();
|
|
367
397
|
dispatch({
|
|
368
398
|
type: 'STREAM_DONE',
|
|
369
|
-
id:
|
|
399
|
+
id: targetId,
|
|
370
400
|
tokensIn: ev.tokensIn,
|
|
371
401
|
tokensOut: ev.tokensOut,
|
|
372
402
|
sources: ev.sources,
|
|
@@ -381,7 +411,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
381
411
|
tokenBuffer.flush();
|
|
382
412
|
dispatch({
|
|
383
413
|
type: 'STREAM_ERROR',
|
|
384
|
-
id:
|
|
414
|
+
id: targetId,
|
|
385
415
|
message: ev.message,
|
|
386
416
|
});
|
|
387
417
|
log.error.error('stream event error', { code: ev.code, message: ev.message });
|
|
@@ -402,7 +432,7 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
402
432
|
const reply = await transport.send(sessionId, content, {
|
|
403
433
|
signal: ctrl.signal,
|
|
404
434
|
attachments,
|
|
405
|
-
metadata: config.metadata,
|
|
435
|
+
metadata: resolveSendMetadata(config.metadata, config.getDynamicMetadata),
|
|
406
436
|
});
|
|
407
437
|
const placeholderId = createId('a');
|
|
408
438
|
dispatch({ type: 'STREAM_START', id: placeholderId });
|
|
@@ -411,6 +441,9 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
411
441
|
dispatch({ type: 'STREAM_DONE', id: placeholderId });
|
|
412
442
|
config.onMessageEnd?.(reply);
|
|
413
443
|
} catch (err) {
|
|
444
|
+
// A user-initiated cancel (cancelStream / newSession) aborts the
|
|
445
|
+
// controller — that's not an error, so don't raise the banner.
|
|
446
|
+
if (ctrl.signal.aborted) return;
|
|
414
447
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
415
448
|
lastErrorRef.current = e;
|
|
416
449
|
dispatch({ type: 'STREAM_ERROR', message: e.message });
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
} from 'react';
|
|
13
13
|
|
|
14
14
|
import type { ChatAttachment } from '../types';
|
|
15
|
-
import { LIMITS } from '../
|
|
16
|
-
import { sanitizeDraft } from '../utils
|
|
15
|
+
import { LIMITS } from '../constants';
|
|
16
|
+
import { sanitizeDraft } from '../utils';
|
|
17
17
|
|
|
18
18
|
export interface UseChatComposerOptions {
|
|
19
19
|
onSubmit: (content: string, attachments: ChatAttachment[]) => void | Promise<void>;
|
|
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
4
4
|
|
|
5
5
|
import { useLocalStorage, useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
6
6
|
|
|
7
|
-
import { CSS_VARS, DEFAULT_SIDEBAR, STORAGE_KEYS } from '../
|
|
7
|
+
import { CSS_VARS, DEFAULT_SIDEBAR, STORAGE_KEYS } from '../constants';
|
|
8
8
|
import type { ChatDisplayMode } from '../types';
|
|
9
9
|
|
|
10
10
|
export interface UseChatLayoutConfig {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
/** Anything with a `.focus()` method. */
|
|
6
|
+
export interface Focusable {
|
|
7
|
+
focus: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Core stream-end focus effect — context-free on purpose so it can run
|
|
12
|
+
* *inside* `ChatProvider` without an import cycle (`context` →
|
|
13
|
+
* `hooks/useAutoFocusOnStreamEnd` → `context`).
|
|
14
|
+
*
|
|
15
|
+
* Fires `resolveTarget().focus()` on the streaming true → false edge.
|
|
16
|
+
* Both `ChatProvider` and the public `useAutoFocusOnStreamEnd` hook
|
|
17
|
+
* delegate here.
|
|
18
|
+
*/
|
|
19
|
+
export function useStreamEndFocus(params: {
|
|
20
|
+
isStreaming: boolean;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
delayMs: number;
|
|
23
|
+
/** Resolves the focus target lazily, at fire time — so it always
|
|
24
|
+
* sees the freshest registered composer handle. */
|
|
25
|
+
resolveTarget: () => Focusable | null;
|
|
26
|
+
}): void {
|
|
27
|
+
const { isStreaming, enabled, delayMs, resolveTarget } = params;
|
|
28
|
+
|
|
29
|
+
// Keep the resolver in a ref so the effect doesn't re-fire when the
|
|
30
|
+
// caller passes a fresh closure each render.
|
|
31
|
+
const resolveRef = useRef(resolveTarget);
|
|
32
|
+
resolveRef.current = resolveTarget;
|
|
33
|
+
|
|
34
|
+
const prevStreamingRef = useRef(isStreaming);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const wasStreaming = prevStreamingRef.current;
|
|
38
|
+
prevStreamingRef.current = isStreaming;
|
|
39
|
+
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
// Only the true → false transition fires focus — toggling `enabled`
|
|
42
|
+
// mid-stream won't steal focus while the user is reading.
|
|
43
|
+
if (!(wasStreaming && !isStreaming)) return;
|
|
44
|
+
|
|
45
|
+
const focusNow = () => resolveRef.current()?.focus();
|
|
46
|
+
|
|
47
|
+
if (delayMs > 0) {
|
|
48
|
+
const id = window.setTimeout(focusNow, delayMs);
|
|
49
|
+
return () => window.clearTimeout(id);
|
|
50
|
+
}
|
|
51
|
+
const raf = requestAnimationFrame(focusNow);
|
|
52
|
+
return () => cancelAnimationFrame(raf);
|
|
53
|
+
}, [isStreaming, enabled, delayMs]);
|
|
54
|
+
}
|