@djangocfg/ui-tools 2.1.429 → 2.1.431
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 +6 -6
- package/src/tools/chat/README.md +21 -0
- 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/messages/Attachments.tsx +72 -8
- package/src/tools/chat/types/attachment.ts +12 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.431",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -329,8 +329,8 @@
|
|
|
329
329
|
"test:watch": "vitest"
|
|
330
330
|
},
|
|
331
331
|
"peerDependencies": {
|
|
332
|
-
"@djangocfg/i18n": "^2.1.
|
|
333
|
-
"@djangocfg/ui-core": "^2.1.
|
|
332
|
+
"@djangocfg/i18n": "^2.1.431",
|
|
333
|
+
"@djangocfg/ui-core": "^2.1.431",
|
|
334
334
|
"consola": "^3.4.2",
|
|
335
335
|
"lodash-es": "^4.18.1",
|
|
336
336
|
"lucide-react": "^0.545.0",
|
|
@@ -405,9 +405,9 @@
|
|
|
405
405
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
406
406
|
},
|
|
407
407
|
"devDependencies": {
|
|
408
|
-
"@djangocfg/i18n": "^2.1.
|
|
409
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
410
|
-
"@djangocfg/ui-core": "^2.1.
|
|
408
|
+
"@djangocfg/i18n": "^2.1.431",
|
|
409
|
+
"@djangocfg/typescript-config": "^2.1.431",
|
|
410
|
+
"@djangocfg/ui-core": "^2.1.431",
|
|
411
411
|
"@types/lodash-es": "^4.17.12",
|
|
412
412
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
413
413
|
"@types/node": "^25.2.3",
|
package/src/tools/chat/README.md
CHANGED
|
@@ -49,6 +49,8 @@ export function MyChat() {
|
|
|
49
49
|
- **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides.
|
|
50
50
|
- **Audio.** Pass `audio: ChatAudioConfig` (sounds map) to `<ChatLauncher>` — the hook runs internally. Built on `@djangocfg/ui-core/hooks/useNotificationSounds`: Safari unlock, persisted mute, per-event toggles, reduced-motion respect. Slack/Linear-style per-event volume scale (error ≈ 0.25, mention ≈ 1.0). **Built-in sounds bundled as base64 data URLs** in the lazy chat chunk (~136KB) — `audio={{}}` just works. `silenced` + `onSoundEvent` for native hosts (cmdop_go / Tauri).
|
|
51
51
|
- **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox.
|
|
52
|
+
- **Unified attach pipeline.** Paperclip, `+` menu, drag-drop and ⌘V/Ctrl+V all funnel through one validated path (`useComposerAttach`); set `showAttachmentButton` / `attach` and the composer ships its own picker. Optional `uploadFn` is the only web/Wails seam.
|
|
53
|
+
- **Paste-as-chunk.** A long plain-text paste (over `attach.pasteTextThreshold`, default 2000 chars — ChatGPT/Claude behaviour) becomes a `type:'text'` "Pasted text" attachment chip instead of flooding the textarea. The chip opens a read-only `PastedTextDialog` preview (rendered via `MarkdownMessage`); the exact payload rides in `ChatAttachment.text` to `onSubmit(content, attachments)`. Toggle with `attach.pasteTextAsChunk`.
|
|
52
54
|
- **Tool-payload dispatcher.** `dispatchToolPayload(matchers, fallback)` — pluggable predicates render `<LazyJsonTree>` / `<LazyMap>` / etc.
|
|
53
55
|
- **Persisted dock prefs.** `headerSlots.modeToggle.persistAs: 'my.key'` stores `mode` / `side` / `width` in localStorage; the toggle lets users flip popover ↔ side and survives reloads. (`useChatDockPrefs()` is now owned by the launcher internally — no longer a consumer-facing hook.)
|
|
54
56
|
- **UX guards.** Auto-focus composer on dock open, two-step Escape (textarea blur → close), click-to-focus on empty message area **and** on the composer surface padding (Slack / Linear / ChatGPT style), `useHotkey('mod+/')` toggle.
|
|
@@ -1023,6 +1025,14 @@ type ComposerModelPickerProps, type ComposerModelOption,
|
|
|
1023
1025
|
type ComposerBannerProps, type ComposerBannerAction, type ComposerRichTextareaProps,
|
|
1024
1026
|
type ComposerTextareaProps, type SendButtonProps, type AttachButtonProps
|
|
1025
1027
|
|
|
1028
|
+
// Attach pipeline — picker + drag-drop + ⌘V (files) + paste-as-chunk (text)
|
|
1029
|
+
useComposerAttach, useComposerAttachContext,
|
|
1030
|
+
fileToAttachment, revokeAttachmentUrl,
|
|
1031
|
+
textToAttachment, deriveTextTitle, DEFAULT_PASTE_TEXT_THRESHOLD,
|
|
1032
|
+
PastedTextDialog,
|
|
1033
|
+
type ComposerAttachConfig, type ComposerAttachHandle, type ComposerAcceptType,
|
|
1034
|
+
type ComposerUploadFn, type ComposerUploadResult, type PastedTextDialogProps
|
|
1035
|
+
|
|
1026
1036
|
// Message blocks
|
|
1027
1037
|
MessageBlocks, createBlockRegistry, BUILTIN_BLOCK_REGISTRY
|
|
1028
1038
|
type MessageBlock, type BlockAppearance, type BlockRegistry,
|
|
@@ -1095,3 +1105,14 @@ Recommended pairing for a "vehicle cards" / "tax breakdown" / "chart" widget on
|
|
|
1095
1105
|
- `Shift+Enter` — newline.
|
|
1096
1106
|
- `Esc` — cancel current streaming turn (when focused inside the composer).
|
|
1097
1107
|
- Launcher hotkey: configurable via `<ChatLauncher hotkey={…}>` (e.g. `⌘/`).
|
|
1108
|
+
|
|
1109
|
+
## Related — conversation-history sidebar
|
|
1110
|
+
|
|
1111
|
+
`ChatRoot` renders a single conversation; the **list of past sessions**
|
|
1112
|
+
(the left sidebar in desktop chat apps) is app-owned, not part of this
|
|
1113
|
+
tool. The recommended way to render it is the **`Tree`** tool with
|
|
1114
|
+
`appearance={{ variant: 'list' }}` — a macOS-sidebar look (no file icons,
|
|
1115
|
+
auto-height rows for *title + meta*, quiet selection) with collapsible
|
|
1116
|
+
group branches (e.g. Today / Yesterday / … or by topic) and a built-in
|
|
1117
|
+
right-click menu (rename / delete). See `data/Tree/README.md` → *Variant —
|
|
1118
|
+
explorer vs list*. cmdop's `SessionsTree` is the reference consumer.
|
|
@@ -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.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode } from 'react';
|
|
4
|
-
import { File as
|
|
3
|
+
import { type ReactNode, useState } from 'react';
|
|
4
|
+
import { File as FileGlyph, FileText, X } from 'lucide-react';
|
|
5
5
|
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
7
|
|
|
8
|
+
import { PastedTextDialog } from '../composer/PastedTextDialog';
|
|
8
9
|
import { useChatDestructiveStyles } from '../styles';
|
|
9
10
|
import type { ChatAttachment } from '../types';
|
|
10
11
|
|
|
@@ -19,6 +20,7 @@ export interface AttachmentRendererArgs {
|
|
|
19
20
|
export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode;
|
|
20
21
|
|
|
21
22
|
export interface AttachmentRendererMap {
|
|
23
|
+
text?: AttachmentRenderer;
|
|
22
24
|
image?: AttachmentRenderer;
|
|
23
25
|
audio?: AttachmentRenderer;
|
|
24
26
|
video?: AttachmentRenderer;
|
|
@@ -143,6 +145,12 @@ export function Attachments(props: AttachmentsProps) {
|
|
|
143
145
|
// ---------------------------------------------------------------------------
|
|
144
146
|
|
|
145
147
|
function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) {
|
|
148
|
+
// "Pasted text" chunks are self-contained — they open their own preview
|
|
149
|
+
// dialog on click rather than going through the generic `onClick`.
|
|
150
|
+
if (attachment.type === 'text') {
|
|
151
|
+
return <TextChunkTile attachment={attachment} onRemove={onRemove} />;
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
const isImage = attachment.type === 'image';
|
|
147
155
|
const isUploading = attachment.status === 'uploading';
|
|
148
156
|
|
|
@@ -150,12 +158,15 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
150
158
|
<img
|
|
151
159
|
src={attachment.thumbnailUrl ?? attachment.url}
|
|
152
160
|
alt={attachment.name ?? 'attachment'}
|
|
153
|
-
|
|
161
|
+
// `--radius-control` (~10px) — the design-system token for value
|
|
162
|
+
// chips / controls (see ui-core base.css). Keeps every chip rounded
|
|
163
|
+
// consistently, a touch softer than the old `rounded-md`.
|
|
164
|
+
className="h-16 w-16 rounded-[var(--radius-control)] object-cover"
|
|
154
165
|
loading="lazy"
|
|
155
166
|
/>
|
|
156
167
|
) : (
|
|
157
|
-
<div className="flex max-w-44 items-center gap-2 rounded-
|
|
158
|
-
<
|
|
168
|
+
<div className="flex max-w-44 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 text-xs">
|
|
169
|
+
<FileGlyph aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
159
170
|
<span className="truncate">{attachment.name ?? 'file'}</span>
|
|
160
171
|
</div>
|
|
161
172
|
);
|
|
@@ -163,14 +174,20 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
163
174
|
return (
|
|
164
175
|
<div className="relative">
|
|
165
176
|
{onClick ? (
|
|
166
|
-
|
|
177
|
+
// Interactive only when the host wired `onClick` — only then does
|
|
178
|
+
// the chip read as clickable (cursor + hover lift + focus ring).
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={onClick}
|
|
182
|
+
className="block cursor-pointer rounded-[var(--radius-control)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
183
|
+
>
|
|
167
184
|
{inner}
|
|
168
185
|
</button>
|
|
169
186
|
) : (
|
|
170
187
|
inner
|
|
171
188
|
)}
|
|
172
189
|
{isUploading ? (
|
|
173
|
-
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-
|
|
190
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[var(--radius-control)] bg-background/70 text-[10px] font-medium">
|
|
174
191
|
{attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
|
|
175
192
|
</div>
|
|
176
193
|
) : null}
|
|
@@ -179,6 +196,53 @@ function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArg
|
|
|
179
196
|
);
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Tile for a `type:'text'` chunk — a two-line "Pasted text" chip in the
|
|
201
|
+
* same surface style as file chips (title + "Pasted text" sublabel).
|
|
202
|
+
* Clicking it opens a read-only `PastedTextDialog` preview of the full
|
|
203
|
+
* payload. Mirrors ChatGPT's pasted-text card + click-to-expand.
|
|
204
|
+
*/
|
|
205
|
+
function TextChunkTile({
|
|
206
|
+
attachment,
|
|
207
|
+
onRemove,
|
|
208
|
+
}: {
|
|
209
|
+
attachment: ChatAttachment;
|
|
210
|
+
onRemove?: () => void;
|
|
211
|
+
}) {
|
|
212
|
+
const [open, setOpen] = useState(false);
|
|
213
|
+
const title = attachment.name ?? 'Pasted text';
|
|
214
|
+
return (
|
|
215
|
+
<div className="relative">
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => setOpen(true)}
|
|
219
|
+
aria-label={`Preview ${title}`}
|
|
220
|
+
className="block cursor-pointer rounded-[var(--radius-control)] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
221
|
+
>
|
|
222
|
+
<div className="flex max-w-56 items-center gap-2 rounded-[var(--radius-control)] border border-border bg-background/60 px-2 py-1.5 transition-colors hover:bg-background">
|
|
223
|
+
{/* Inner glyph plate keeps the tighter `rounded-md` — a nested
|
|
224
|
+
surface should round a notch less than its container
|
|
225
|
+
(concentric radii) so the corners read cleanly. */}
|
|
226
|
+
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
|
|
227
|
+
<FileText aria-hidden className="size-4" />
|
|
228
|
+
</span>
|
|
229
|
+
<span className="flex min-w-0 flex-col">
|
|
230
|
+
<span className="truncate text-xs font-medium">{title}</span>
|
|
231
|
+
<span className="truncate text-[11px] text-muted-foreground">Pasted text</span>
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
</button>
|
|
235
|
+
{onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
|
|
236
|
+
<PastedTextDialog
|
|
237
|
+
open={open}
|
|
238
|
+
onOpenChange={setOpen}
|
|
239
|
+
text={attachment.text ?? ''}
|
|
240
|
+
title={title}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
182
246
|
function RemoveBtn({ onRemove }: { onRemove: () => void }) {
|
|
183
247
|
const styles = useChatDestructiveStyles();
|
|
184
248
|
return (
|
|
@@ -187,7 +251,7 @@ function RemoveBtn({ onRemove }: { onRemove: () => void }) {
|
|
|
187
251
|
aria-label="Remove attachment"
|
|
188
252
|
onClick={onRemove}
|
|
189
253
|
className={cn(
|
|
190
|
-
'absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground',
|
|
254
|
+
'absolute -right-1.5 -top-1.5 grid h-4 w-4 cursor-pointer place-items-center rounded-full border border-border bg-background text-muted-foreground',
|
|
191
255
|
styles.hoverStrong,
|
|
192
256
|
)}
|
|
193
257
|
>
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
export interface ChatAttachment {
|
|
6
6
|
id: string;
|
|
7
|
-
type: 'image' | 'file' | 'audio' | 'video';
|
|
7
|
+
type: 'image' | 'file' | 'audio' | 'video' | 'text';
|
|
8
|
+
/**
|
|
9
|
+
* Object-URL / remote URL for binary attachments. Empty string for
|
|
10
|
+
* `type:'text'` chunks — those carry their payload in `text`, not a URL.
|
|
11
|
+
*/
|
|
8
12
|
url: string;
|
|
9
13
|
thumbnailUrl?: string;
|
|
10
14
|
name?: string;
|
|
@@ -15,6 +19,13 @@ export interface ChatAttachment {
|
|
|
15
19
|
progress?: number;
|
|
16
20
|
/** Extracted text from images (OCR). */
|
|
17
21
|
ocrText?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Raw text payload for `type:'text'` chunks — the "Pasted text"
|
|
24
|
+
* attachment minted when a long paste is converted into a chip
|
|
25
|
+
* instead of dumped into the textarea (ChatGPT/Claude behaviour).
|
|
26
|
+
* Preserves exact content: newlines, indentation, stack traces.
|
|
27
|
+
*/
|
|
28
|
+
text?: string;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export interface ChatSource {
|
|
@@ -376,6 +376,7 @@ Defaults are English. Function-shaped entries (`confirmDeleteTitle(count)`, `con
|
|
|
376
376
|
```tsx
|
|
377
377
|
<TreeRoot
|
|
378
378
|
appearance={{
|
|
379
|
+
variant: 'explorer', // 'explorer' (default) | 'list'
|
|
379
380
|
density: 'cozy', // 'compact' | 'cozy' | 'comfortable'
|
|
380
381
|
accent: 'default', // 'subtle' | 'default' | 'strong'
|
|
381
382
|
radius: 'sm', // 'none' | 'sm' | 'md'
|
|
@@ -387,6 +388,35 @@ Defaults are English. Function-shaped entries (`confirmDeleteTitle(count)`, `con
|
|
|
387
388
|
/>
|
|
388
389
|
```
|
|
389
390
|
|
|
391
|
+
### Variant — `explorer` vs `list`
|
|
392
|
+
|
|
393
|
+
`variant` is a high-level look that sets sensible defaults for three
|
|
394
|
+
fields (`hideLeafIcons`, `hideFolderIcons`, `rowSizing`,
|
|
395
|
+
`showActiveIndicator`); any of those passed explicitly still wins.
|
|
396
|
+
|
|
397
|
+
| Variant | Look | Defaults |
|
|
398
|
+
| --- | --- | --- |
|
|
399
|
+
| `'explorer'` (default) | Classic file/folder tree | leaf + folder icons, **fixed** single-line rows, active bar on |
|
|
400
|
+
| `'list'` | macOS-sidebar / **chat-history** list | **no icons** (chevron only on groups), **auto-height** rows (multi-line label friendly), quiet selection (no active bar) |
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// A grouped chat-history sidebar (date / topic buckets → sessions),
|
|
404
|
+
// multi-line rows (title + dimmed meta), no file glyphs:
|
|
405
|
+
<TreeRoot<Payload>
|
|
406
|
+
data={groupedSessions}
|
|
407
|
+
getItemName={(n) => label(n)}
|
|
408
|
+
appearance={{ variant: 'list', indent: 10 }}
|
|
409
|
+
showIndentGuides={false}
|
|
410
|
+
renderLabel={({ node }) => /* title + meta */ null}
|
|
411
|
+
/>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Fine-grained fields (override the variant):
|
|
415
|
+
|
|
416
|
+
- `hideLeafIcons` / `hideFolderIcons` — drop the per-row icon (chevron stays).
|
|
417
|
+
- `rowSizing: 'fixed' | 'auto'` — `auto` turns `rowHeight` into a *minimum*
|
|
418
|
+
so a multi-line label grows the row instead of overflowing.
|
|
419
|
+
|
|
390
420
|
The resolved appearance is exposed on the root container as CSS variables:
|
|
391
421
|
|
|
392
422
|
```
|
|
@@ -175,7 +175,16 @@ function TreeRowRaw<T>({ row, className }: TreeRowProps<T>) {
|
|
|
175
175
|
tabIndex={-1}
|
|
176
176
|
style={{
|
|
177
177
|
paddingLeft: 6 + level * appearance.indent,
|
|
178
|
-
|
|
178
|
+
// `auto` sizing lets a multi-line label (e.g. title + meta in a
|
|
179
|
+
// chat-history list) grow the row instead of overflowing a fixed
|
|
180
|
+
// height. `fixed` keeps the classic single-line explorer row.
|
|
181
|
+
...(appearance.rowSizing === 'auto'
|
|
182
|
+
? {
|
|
183
|
+
minHeight: 'var(--tree-row-height)',
|
|
184
|
+
paddingTop: 4,
|
|
185
|
+
paddingBottom: 4,
|
|
186
|
+
}
|
|
187
|
+
: { height: 'var(--tree-row-height)' }),
|
|
179
188
|
gap: 'var(--tree-gap)',
|
|
180
189
|
}}
|
|
181
190
|
{...(dnd.active ? draggable.listeners : {})}
|
|
@@ -234,7 +243,7 @@ function TreeRowRaw<T>({ row, className }: TreeRowProps<T>) {
|
|
|
234
243
|
/>
|
|
235
244
|
) : renderIcon ? (
|
|
236
245
|
renderIcon(slot)
|
|
237
|
-
) : (
|
|
246
|
+
) : (isFolder ? appearance.hideFolderIcons : appearance.hideLeafIcons) ? null : (
|
|
238
247
|
<TreeIcon isFolder={isFolder} isExpanded={isExpanded} />
|
|
239
248
|
)}
|
|
240
249
|
|