@djangocfg/ui-tools 2.1.430 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.430",
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.430",
333
- "@djangocfg/ui-core": "^2.1.430",
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.430",
409
- "@djangocfg/typescript-config": "^2.1.430",
410
- "@djangocfg/ui-core": "^2.1.430",
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",
@@ -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 FileIcon, X } from 'lucide-react';
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
- className="h-16 w-16 rounded-md object-cover"
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-md border border-border bg-background/60 px-2 py-1.5 text-xs">
158
- <FileIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
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
- <button type="button" onClick={onClick} className="block">
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-md bg-background/70 text-[10px] font-medium">
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
- height: 'var(--tree-row-height)',
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