@djangocfg/ui-tools 2.1.447 → 2.1.449

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":["__name","useCallback","useSyncExternalStore"],"mappings":";;;;;AA6DA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgBA,wBAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgBA,wBAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAYC,iBAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAOC,0BAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgBF,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.cjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
1
+ {"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":["__name","useCallback","useSyncExternalStore"],"mappings":";;;;;AAoEA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgBA,wBAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgBA,wBAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgBA,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAYC,iBAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAOC,0BAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgBF,wBAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.cjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n /** Attach already-resolved files through the composer's validated attach\n * pipeline (size / type / count checks + upload + staging tray) — the\n * same entry point the paperclip, paste and drag-drop use. Present only\n * when the composer mounts the attach pipeline. A native shell (WKWebView /\n * WebView2) that intercepts an OS file drop hands the bytes here so a\n * native drop behaves exactly like an in-page one. */\n attachFiles?: (files: File[]) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
@@ -18,6 +18,13 @@ interface ComposerHandle {
18
18
  * and final transcripts through this without owning a controlled
19
19
  * binding. */
20
20
  setValue?: (value: string) => void;
21
+ /** Attach already-resolved files through the composer's validated attach
22
+ * pipeline (size / type / count checks + upload + staging tray) — the
23
+ * same entry point the paperclip, paste and drag-drop use. Present only
24
+ * when the composer mounts the attach pipeline. A native shell (WKWebView /
25
+ * WebView2) that intercepts an OS file drop hands the bytes here so a
26
+ * native drop behaves exactly like an in-page one. */
27
+ attachFiles?: (files: File[]) => void;
21
28
  }
22
29
  /**
23
30
  * `@djangocfg/ui-tools/composer-registry`
@@ -18,6 +18,13 @@ interface ComposerHandle {
18
18
  * and final transcripts through this without owning a controlled
19
19
  * binding. */
20
20
  setValue?: (value: string) => void;
21
+ /** Attach already-resolved files through the composer's validated attach
22
+ * pipeline (size / type / count checks + upload + staging tray) — the
23
+ * same entry point the paperclip, paste and drag-drop use. Present only
24
+ * when the composer mounts the attach pipeline. A native shell (WKWebView /
25
+ * WebView2) that intercepts an OS file drop hands the bytes here so a
26
+ * native drop behaves exactly like an in-page one. */
27
+ attachFiles?: (files: File[]) => void;
21
28
  }
22
29
  /**
23
30
  * `@djangocfg/ui-tools/composer-registry`
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":[],"mappings":";;;AA6DA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgB,MAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgB,MAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAO,oBAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.mjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
1
+ {"version":3,"sources":["../../src/tools/integration/ComposerRegistry/index.ts"],"names":[],"mappings":";;;AAoEA,IAAI,MAAA,GAAgC,IAAA;AACpC,IAAM,SAAA,uBAAgB,GAAA,EAAc;AAG7B,SAAS,iBAAiB,MAAA,EAAqC;AACpE,EAAA,MAAA,GAAS,MAAA;AACT,EAAA,KAAA,MAAW,EAAA,IAAM,SAAA,EAAW,EAAA,CAAG,MAAM,CAAA;AACvC;AAHgB,MAAA,CAAA,gBAAA,EAAA,kBAAA,CAAA;AAST,SAAS,eAAe,MAAA,EAAoC;AACjE,EAAA,gBAAA,CAAiB,MAAM,CAAA;AACvB,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,MAAA,KAAW,MAAA,EAAQ,gBAAA,CAAiB,IAAI,CAAA;AAAA,EAC9C,CAAA;AACF;AALgB,MAAA,CAAA,cAAA,EAAA,gBAAA,CAAA;AAQT,SAAS,iBAAA,GAA2C;AACzD,EAAA,OAAO,MAAA;AACT;AAFgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAKT,SAAS,kBAAkB,QAAA,EAAgC;AAChE,EAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,EAAA,OAAO,MAAM;AACX,IAAA,SAAA,CAAU,OAAO,QAAQ,CAAA;AAAA,EAC3B,CAAA;AACF;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA;AAYT,SAAS,iBAAA,GAA2C;AACzD,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,QAAA,KAAyB;AACtD,IAAA,OAAO,kBAAkB,QAAQ,CAAA;AAAA,EACnC,CAAA,EAAG,EAAE,CAAA;AACL,EAAA,OAAO,oBAAA,CAAqB,SAAA,EAAW,iBAAA,EAAmB,MAAM,IAAI,CAAA;AACtE;AALgB,MAAA,CAAA,iBAAA,EAAA,mBAAA,CAAA","file":"index.mjs","sourcesContent":["'use client';\n\nimport { useCallback, useSyncExternalStore } from 'react';\n\n/**\n * Minimal imperative handle every text-editor surface implements so\n * an external tool (voice dictation, command palette, AI suggestion)\n * can read/write its text content without traversing React.\n *\n * Methods are optional so a host can register a partial handle\n * (e.g. only `getValue` + `setValue`), and the caller checks before use.\n */\nexport interface ComposerHandle {\n /** Move keyboard focus into the composer's editable surface. */\n focus: () => void;\n /** Move the caret to the very end of the input. */\n moveCursorToEnd?: () => void;\n /** Read the current draft text. Voice dictation anchors partial\n * transcripts onto the user's already-typed prefix via this. */\n getValue?: () => string;\n /** Replace the current draft text. Voice dictation pushes interim\n * and final transcripts through this without owning a controlled\n * binding. */\n setValue?: (value: string) => void;\n /** Attach already-resolved files through the composer's validated attach\n * pipeline (size / type / count checks + upload + staging tray) — the\n * same entry point the paperclip, paste and drag-drop use. Present only\n * when the composer mounts the attach pipeline. A native shell (WKWebView /\n * WebView2) that intercepts an OS file drop hands the bytes here so a\n * native drop behaves exactly like an in-page one. */\n attachFiles?: (files: File[]) => void;\n}\n\n/**\n * `@djangocfg/ui-tools/composer-registry`\n *\n * Cross-tool bridge: the currently-active text composer's handle.\n *\n * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):\n * register their composer's imperative handle via `attachComposer`.\n *\n * Consumer side (`@djangocfg/ui-tools/speech-recognition`):\n * reads the active handle via `useActiveComposer`/`getActiveComposer`\n * and pipes voice transcripts into it.\n *\n * Why this lives in its own subpath (not inside `chat`)\n * ----------------------------------------------------\n * `chat` and `speech-recognition` are sibling subpath exports. If the\n * registry lived inside `chat`, then `speech-recognition` would have\n * to reach into it via a cross-tool relative import — and under Vite\n * dev's dependency optimizer that file ends up loaded TWICE (once via\n * the `./chat` URL, once via the `./speech-recognition` relative-up\n * URL), giving the producer and the consumer two separate `let active`\n * slots. The active handle registered by chat would be invisible to\n * speech-recognition (and vice versa).\n *\n * Putting the registry in its own dedicated subpath (a single tool\n * that NEITHER chat nor speech-recognition cross-import — they both\n * import this one as their dependency) means Vite resolves it from a\n * single URL across the whole graph. One module instance, one shared\n * `active` slot.\n *\n * Semantics: one active composer per realm. The most recent\n * `registerComposer(handle)` wins; `registerComposer(null)` clears it.\n */\n\ntype Listener = (handle: ComposerHandle | null) => void;\n\nlet active: ComposerHandle | null = null;\nconst listeners = new Set<Listener>();\n\n/** Set or replace the active composer handle. Pass `null` to clear. */\nexport function registerComposer(handle: ComposerHandle | null): void {\n active = handle;\n for (const fn of listeners) fn(active);\n}\n\n/**\n * Convenience for components: register on mount, unregister on\n * unmount. Returns a cleanup function suitable for `useEffect`.\n */\nexport function attachComposer(handle: ComposerHandle): () => void {\n registerComposer(handle);\n return () => {\n if (active === handle) registerComposer(null);\n };\n}\n\n/** Read the current active handle (no subscription). */\nexport function getActiveComposer(): ComposerHandle | null {\n return active;\n}\n\n/** Subscribe to handle changes; returns an unsubscribe fn. */\nexport function subscribeComposer(listener: Listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\n/**\n * React hook: re-renders the caller whenever the active composer\n * changes. Built on `useSyncExternalStore` so concurrent rendering,\n * SSR, and dev-mode strict-effects all behave correctly.\n */\nexport function useActiveComposer(): ComposerHandle | null {\n const subscribe = useCallback((onChange: () => void) => {\n return subscribeComposer(onChange);\n }, []);\n return useSyncExternalStore(subscribe, getActiveComposer, () => null);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.447",
3
+ "version": "2.1.449",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -334,8 +334,8 @@
334
334
  "test:watch": "vitest"
335
335
  },
336
336
  "peerDependencies": {
337
- "@djangocfg/i18n": "^2.1.447",
338
- "@djangocfg/ui-core": "^2.1.447",
337
+ "@djangocfg/i18n": "^2.1.449",
338
+ "@djangocfg/ui-core": "^2.1.449",
339
339
  "consola": "^3.4.2",
340
340
  "lodash-es": "^4.18.1",
341
341
  "lucide-react": "^0.545.0",
@@ -418,9 +418,9 @@
418
418
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
419
419
  },
420
420
  "devDependencies": {
421
- "@djangocfg/i18n": "^2.1.447",
422
- "@djangocfg/typescript-config": "^2.1.447",
423
- "@djangocfg/ui-core": "^2.1.447",
421
+ "@djangocfg/i18n": "^2.1.449",
422
+ "@djangocfg/typescript-config": "^2.1.449",
423
+ "@djangocfg/ui-core": "^2.1.449",
424
424
  "@types/lodash-es": "^4.17.12",
425
425
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
426
426
  "@types/node": "^25.2.3",
@@ -19,6 +19,7 @@ import { SlashMenu } from './slash/SlashMenu';
19
19
  import { useSlashCommands } from './slash/useSlashCommands';
20
20
  import { useComposerActions } from './useComposerActions';
21
21
  import { useComposerAttach } from './useComposerAttach';
22
+ import { usePaneDrop } from './usePaneDrop';
22
23
  import type {
23
24
  ComposerAppearance,
24
25
  ComposerAttachConfig,
@@ -43,6 +44,21 @@ export interface ComposerProps {
43
44
  /** Tune the built-in attach pipeline — accepted types, size / count
44
45
  * caps, paste, and the optional upload transport. */
45
46
  attach?: ComposerAttachConfig;
47
+ /**
48
+ * How large the file-drop target is.
49
+ *
50
+ * - `'composer'` (default) — only the composer surface accepts a drop
51
+ * (the historical behaviour).
52
+ * - `'pane'` — the WHOLE chat pane is a drop target: dragging a file
53
+ * anywhere over the chat shows a full-pane "Drop files here" overlay
54
+ * and drops route through the same validated `attachFiles` pipeline.
55
+ * Right when a pane hosts a single conversation (one chat open at a
56
+ * time) — a small composer-only target is easy to miss. The overlay
57
+ * covers the nearest positioned ancestor of the composer, so the host
58
+ * must give that ancestor `position: relative` (the chat pane usually
59
+ * already is). Falls back to the viewport if none is found.
60
+ */
61
+ dropScope?: 'composer' | 'pane';
46
62
  /** Override the built-in file picker. When set, the paperclip and the
47
63
  * `+`-menu attach item call this instead of opening the native picker
48
64
  * — for hosts that drive their own (e.g. a Wails native dialog). */
@@ -162,6 +178,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
162
178
  disabled,
163
179
  showAttachmentButton = false,
164
180
  attach,
181
+ dropScope = 'composer',
165
182
  onPickFiles,
166
183
  className,
167
184
  textareaClassName,
@@ -213,6 +230,13 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
213
230
  // In that case the slot registers its own imperative handle; skip
214
231
  // ours so we don't overwrite it (child effects run before parent).
215
232
  const hasCustomTextarea = !!slots?.Textarea;
233
+ // The plain-textarea handle. `attachFiles` is threaded in via a ref (set
234
+ // once `attachHandle` exists, below) so the native OS-drop bridge
235
+ // (getActiveComposer().attachFiles) works for this path too, without a
236
+ // temporal-dead-zone on `attachHandle` (declared further down). The
237
+ // custom-textarea (TipTap) path carries attach the same way via
238
+ // ComposerRichTextarea reading the attach context.
239
+ const attachFilesRef = useRef<((files: File[]) => void) | undefined>(undefined);
216
240
  useEffect(() => {
217
241
  if (hasCustomTextarea) return;
218
242
  return attachComposer({
@@ -225,6 +249,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
225
249
  },
226
250
  getValue: () => getValueRef.current(),
227
251
  setValue: composerSetValue,
252
+ attachFiles: (files) => attachFilesRef.current?.(files),
228
253
  });
229
254
  }, [hasCustomTextarea, composerFocus, composerSetValue, textareaRef]);
230
255
 
@@ -338,6 +363,9 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
338
363
  disabled: isDisabled,
339
364
  pasteScopeRef: surfaceRef,
340
365
  });
366
+ // Feed the plain-textarea registry handle its attach entry (see the
367
+ // attachComposer effect above — it reads through this ref to dodge a TDZ).
368
+ attachFilesRef.current = attachEnabled ? attachHandle.attachFiles : undefined;
341
369
  // The resolved click handler: host override wins, else the built-in
342
370
  // picker. `undefined` when attaching is not enabled at all.
343
371
  const pickFiles = !attachEnabled
@@ -365,17 +393,38 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
365
393
  dragDepth.current = Math.max(0, dragDepth.current - 1);
366
394
  if (dragDepth.current === 0) setIsDragging(false);
367
395
  };
396
+ // When `dropScope==='pane'` the pane-level hook (usePaneDrop) owns the drop
397
+ // for the WHOLE pane, including the composer surface. Skip the composer's own
398
+ // surface drop then so a file landing on the composer isn't attached twice.
399
+ const paneScope = dropScope === 'pane' && attachEnabled && !isDisabled;
368
400
  const handleDrop = (e: React.DragEvent) => {
369
- if (!attachEnabled || isDisabled) return;
401
+ if (!attachEnabled || isDisabled || paneScope) return;
370
402
  e.preventDefault();
371
403
  dragDepth.current = 0;
372
404
  setIsDragging(false);
373
405
  const dropped = Array.from(e.dataTransfer.files ?? []);
374
- // eslint-disable-next-line no-console
375
- console.log('[composer] handleDrop files=%d names=%o', dropped.length, dropped.map((f) => f.name));
376
406
  if (dropped.length > 0) attachHandle.attachFiles(dropped);
377
407
  };
378
408
 
409
+ // Pane-wide drop target: dragging a file anywhere over the chat pane shows a
410
+ // full-pane overlay and routes the drop through the same validated pipeline.
411
+ const rootRef = useRef<HTMLDivElement>(null);
412
+ const { paneOverlay } = usePaneDrop({
413
+ enabled: paneScope,
414
+ composerRef: rootRef,
415
+ onFiles: attachHandle.attachFiles,
416
+ });
417
+ // Merge the internal root ref (pane-drop needs the composer node to locate
418
+ // its pane) with the caller's forwarded ref.
419
+ const setRootRef = useCallback(
420
+ (node: HTMLDivElement | null) => {
421
+ rootRef.current = node;
422
+ if (typeof ref === 'function') ref(node);
423
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
424
+ },
425
+ [ref],
426
+ );
427
+
379
428
  // Send, then return focus to the composer. Clicking the Send button
380
429
  // moves focus to that button; after the message goes the caret should
381
430
  // be back in the input so the user can keep typing without reaching for
@@ -618,7 +667,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
618
667
  <ComposerSizeProvider value={size}>
619
668
  <AttachProvider value={attachHandle}>
620
669
  <div
621
- ref={ref}
670
+ ref={setRootRef}
622
671
  {...dragProps}
623
672
  onKeyDownCapture={handleSlashKeyDownCapture}
624
673
  className={cn(
@@ -632,7 +681,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
632
681
  )}
633
682
  >
634
683
  {hiddenFileInput}
635
- {dragOverlay}
684
+ {!paneScope && dragOverlay}
685
+ {paneOverlay}
636
686
  {slashOverlay}
637
687
  {trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
638
688
  <div
@@ -743,7 +793,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
743
793
  <ComposerSizeProvider value={size}>
744
794
  <AttachProvider value={attachHandle}>
745
795
  <div
746
- ref={ref}
796
+ ref={setRootRef}
747
797
  {...dragProps}
748
798
  onKeyDownCapture={handleSlashKeyDownCapture}
749
799
  className={cn(
@@ -756,7 +806,8 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
756
806
  )}
757
807
  >
758
808
  {hiddenFileInput}
759
- {dragOverlay}
809
+ {!paneScope && dragOverlay}
810
+ {paneOverlay}
760
811
  {slashOverlay}
761
812
  {trayNode ? <div className="mb-2">{trayNode}</div> : null}
762
813
  {surfaceWrapper}
@@ -9,6 +9,7 @@ import {
9
9
  type SlashCommandInfo,
10
10
  } from '@djangocfg/ui-tools/markdown-editor';
11
11
 
12
+ import { useComposerAttachContext } from './AttachContext';
12
13
  import { useRegisterComposer } from '../hooks/useAutoFocusOnStreamEnd';
13
14
  import type { ComposerSize, ComposerTextareaProps } from './types';
14
15
 
@@ -95,11 +96,19 @@ export function ComposerRichTextarea({
95
96
  [],
96
97
  );
97
98
  const getValue = useCallback(() => composer.value, [composer.value]);
99
+ // This TipTap slot OWNS the active composer handle (it wins over the
100
+ // plain-textarea registration in Composer.tsx for a custom textarea). So it
101
+ // must also carry `attachFiles`, else a native shell's OS-drop bridge
102
+ // (window.__cmdopFileDrop → getActiveComposer().attachFiles) finds no attach
103
+ // entry. Read it from the attach context the composer provides.
104
+ const attach = useComposerAttachContext();
105
+ const attachFiles = attach?.attachFiles;
98
106
  useRegisterComposer({
99
107
  focus,
100
108
  moveCursorToEnd,
101
109
  getValue,
102
110
  setValue: composer.setValue,
111
+ attachFiles,
103
112
  });
104
113
 
105
114
  return (
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
4
4
 
5
+ import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
6
+
5
7
  import { buildAcceptString, useClipboardPaste } from '../../forms/Uploader';
6
8
  import type { UseChatComposerReturn } from '../hooks/useChatComposer';
7
9
  import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
@@ -126,6 +128,7 @@ export function useComposerAttach({
126
128
  const cap = maxFiles ?? Number.POSITIVE_INFINITY;
127
129
 
128
130
  let slots = cap - c.attachments.length;
131
+ let staged = 0;
129
132
  for (const file of files) {
130
133
  if (slots <= 0) {
131
134
  onRejectRef.current?.(file, 'count');
@@ -147,6 +150,17 @@ export function useComposerAttach({
147
150
  runUpload(file, attachment.id, attachment.url);
148
151
  }
149
152
  slots -= 1;
153
+ staged += 1;
154
+ }
155
+
156
+ // After a successful attach (drop / paste / picker all land here), move
157
+ // the caret into the composer so the user can type the message
158
+ // immediately without reaching for the mouse — a dropped file is the
159
+ // start of composing, not the end. Route through the registry (not the
160
+ // plain-textarea `c.focus()`), so it also focuses the TipTap rich
161
+ // editor. rAF lets the staging tray commit first.
162
+ if (staged > 0) {
163
+ requestAnimationFrame(() => getActiveComposer()?.focus?.());
150
164
  }
151
165
  },
152
166
  [disabled, maxFiles, maxSizeBytes, accept, runUpload],
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * usePaneDrop — widen the composer's file-drop target to the whole chat pane.
5
+ *
6
+ * The composer's own drag surface (`data-wails-dropzone` div) is a small
7
+ * target easy to miss. When a pane hosts one conversation at a time,
8
+ * dropping a file anywhere over the chat should attach it — the ChatGPT /
9
+ * Telegram / Slack model. This hook implements that WITHOUT the composer
10
+ * needing to own the pane's DOM:
11
+ *
12
+ * - It listens for drag events at the WINDOW level (capture) and gates on
13
+ * `dataTransfer.types.includes('Files')` — so a text/selection drag never
14
+ * triggers it.
15
+ * - The overlay is positioned over the composer's nearest POSITIONED
16
+ * ancestor (the chat pane, normally `position: relative`). If none is
17
+ * found it falls back to the viewport. A ResizeObserver + scroll/resize
18
+ * listeners keep the rect fresh while a drag is in flight.
19
+ * - The drop routes through the SAME `onFiles` (the composer's validated
20
+ * `attachFiles`) as the paperclip / paste / composer-drop paths — no
21
+ * second attach lane.
22
+ *
23
+ * Enabled only when `dropScope === 'pane'` and attaching is on; otherwise the
24
+ * hook is inert and returns a null overlay (the composer keeps its own
25
+ * surface-scoped drop).
26
+ */
27
+
28
+ import { useCallback, useEffect, useRef, useState } from 'react';
29
+ import { createPortal } from 'react-dom';
30
+
31
+ import { cn } from '@djangocfg/ui-core/lib';
32
+
33
+ export interface UsePaneDropOptions {
34
+ /** Master switch — only bind when the host asked for pane scope + attach on. */
35
+ enabled: boolean;
36
+ /** The composer root — used to find the pane (nearest positioned ancestor). */
37
+ composerRef: React.RefObject<HTMLElement | null>;
38
+ /** Funnel dropped files through the composer's validated attach pipeline. */
39
+ onFiles: (files: File[]) => void;
40
+ /** Overlay caption. Defaults to a plain English string. */
41
+ label?: string;
42
+ }
43
+
44
+ export interface UsePaneDropReturn {
45
+ /** Portal overlay node (or null). Render it anywhere — it portals to body. */
46
+ paneOverlay: React.ReactNode;
47
+ }
48
+
49
+ /** Walk up to the nearest ancestor establishing a positioning context. */
50
+ function nearestPositionedAncestor(el: HTMLElement | null): HTMLElement | null {
51
+ let node = el?.parentElement ?? null;
52
+ while (node) {
53
+ const pos = getComputedStyle(node).position;
54
+ if (pos === 'relative' || pos === 'absolute' || pos === 'fixed' || pos === 'sticky') {
55
+ return node;
56
+ }
57
+ node = node.parentElement;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function usePaneDrop(options: UsePaneDropOptions): UsePaneDropReturn {
63
+ const { enabled, composerRef, onFiles, label = 'Drop files here' } = options;
64
+
65
+ const [dragging, setDragging] = useState(false);
66
+ const [rect, setRect] = useState<DOMRect | null>(null);
67
+ // Counter of enter/leave — a single boolean flickers as the cursor crosses
68
+ // child element boundaries (each generates leave→enter). Depth hits 0 only
69
+ // when the pointer truly left the window.
70
+ const depth = useRef(0);
71
+ const paneRef = useRef<HTMLElement | null>(null);
72
+
73
+ const measure = useCallback(() => {
74
+ const pane = paneRef.current;
75
+ setRect(pane ? pane.getBoundingClientRect() : null);
76
+ }, []);
77
+
78
+ useEffect(() => {
79
+ if (!enabled) {
80
+ // Ensure a scope flip mid-drag can't leave a stuck overlay.
81
+ depth.current = 0;
82
+ setDragging(false);
83
+ return;
84
+ }
85
+
86
+ const hasFiles = (e: DragEvent) =>
87
+ Array.from(e.dataTransfer?.types ?? []).includes('Files');
88
+
89
+ const onDragEnter = (e: DragEvent) => {
90
+ if (!hasFiles(e)) return;
91
+ // Resolve the pane lazily on the first enter (the composer is mounted by now).
92
+ paneRef.current = nearestPositionedAncestor(composerRef.current);
93
+ depth.current += 1;
94
+ if (!dragging) {
95
+ measure();
96
+ setDragging(true);
97
+ }
98
+ };
99
+ const onDragOver = (e: DragEvent) => {
100
+ if (!hasFiles(e)) return;
101
+ // Allow the drop (else the browser rejects it and onDrop never fires).
102
+ e.preventDefault();
103
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
104
+ };
105
+ const onDragLeave = (e: DragEvent) => {
106
+ if (!hasFiles(e)) return;
107
+ depth.current = Math.max(0, depth.current - 1);
108
+ if (depth.current === 0) setDragging(false);
109
+ };
110
+ const onDrop = (e: DragEvent) => {
111
+ if (!hasFiles(e)) return;
112
+ e.preventDefault();
113
+ depth.current = 0;
114
+ setDragging(false);
115
+ const files = Array.from(e.dataTransfer?.files ?? []);
116
+ if (files.length > 0) onFiles(files);
117
+ };
118
+
119
+ // Native-shell drag signal. WKWebView / WebView2 intercept an OS file drag
120
+ // natively and NEVER emit DOM dragenter/leave, so the listeners above can't
121
+ // light the overlay. The native side instead fans out a `cmdop:native-drag`
122
+ // window event (see cmdop transport `native-drop.ts`) as the drag enters /
123
+ // leaves; we drive the overlay off that. The actual files still arrive via
124
+ // the native `window.__cmdopFileDrop` → attachFiles path, not a DOM drop.
125
+ const onNativeDrag = (e: Event) => {
126
+ const active = !!(e as CustomEvent<{ active?: boolean }>).detail?.active;
127
+ if (active) {
128
+ paneRef.current = nearestPositionedAncestor(composerRef.current);
129
+ measure();
130
+ setDragging(true);
131
+ } else {
132
+ depth.current = 0;
133
+ setDragging(false);
134
+ }
135
+ };
136
+
137
+ // Capture phase so we see the drag before any inner handler stops it.
138
+ window.addEventListener('dragenter', onDragEnter, true);
139
+ window.addEventListener('dragover', onDragOver, true);
140
+ window.addEventListener('dragleave', onDragLeave, true);
141
+ window.addEventListener('drop', onDrop, true);
142
+ window.addEventListener('cmdop:native-drag', onNativeDrag);
143
+ return () => {
144
+ window.removeEventListener('dragenter', onDragEnter, true);
145
+ window.removeEventListener('dragover', onDragOver, true);
146
+ window.removeEventListener('dragleave', onDragLeave, true);
147
+ window.removeEventListener('drop', onDrop, true);
148
+ window.removeEventListener('cmdop:native-drag', onNativeDrag);
149
+ };
150
+ }, [enabled, composerRef, onFiles, measure, dragging]);
151
+
152
+ // Keep the overlay aligned with the pane while a drag is in flight.
153
+ useEffect(() => {
154
+ if (!dragging) return;
155
+ const onChange = () => measure();
156
+ window.addEventListener('scroll', onChange, true);
157
+ window.addEventListener('resize', onChange);
158
+ const ro = paneRef.current ? new ResizeObserver(onChange) : null;
159
+ if (ro && paneRef.current) ro.observe(paneRef.current);
160
+ return () => {
161
+ window.removeEventListener('scroll', onChange, true);
162
+ window.removeEventListener('resize', onChange);
163
+ ro?.disconnect();
164
+ };
165
+ }, [dragging, measure]);
166
+
167
+ if (!enabled || !dragging || typeof document === 'undefined') {
168
+ return { paneOverlay: null };
169
+ }
170
+
171
+ // Cover the pane rect (or the viewport if we couldn't find a positioned
172
+ // ancestor). `pointer-events-none` so the underlying drop target still
173
+ // receives the drop event — the overlay is a pure visual affordance.
174
+ const style: React.CSSProperties = rect
175
+ ? { position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height }
176
+ : { position: 'fixed', inset: 0 };
177
+
178
+ const overlay = (
179
+ <div
180
+ aria-hidden
181
+ className="pointer-events-none z-50"
182
+ style={style}
183
+ >
184
+ <div
185
+ className={cn(
186
+ 'absolute inset-2 flex flex-col items-center justify-center gap-3 rounded-2xl',
187
+ 'border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-[2px]',
188
+ 'transition-opacity duration-150',
189
+ )}
190
+ >
191
+ <div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 ring-1 ring-primary/30">
192
+ <svg
193
+ width="26" height="26" viewBox="0 0 24 24" fill="none"
194
+ stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
195
+ className="text-primary"
196
+ >
197
+ <path d="M12 3v12" />
198
+ <path d="m7 10 5 5 5-5" />
199
+ <path d="M5 21h14" />
200
+ </svg>
201
+ </div>
202
+ <span className="text-sm font-medium text-foreground/90">{label}</span>
203
+ </div>
204
+ </div>
205
+ );
206
+
207
+ return { paneOverlay: createPortal(overlay, document.body) };
208
+ }
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  import { getActiveComposer } from '@djangocfg/ui-tools/composer-registry';
14
14
 
15
- import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
15
+ import type { ChatConfig, ChatLabels, ChatMessage, ChatTransport } from '../types';
16
16
  import { DEFAULT_LABELS } from '../types';
17
17
  import type { BlockRegistry } from '../messages/blocks';
18
18
 
@@ -63,6 +63,15 @@ export interface ChatProviderProps {
63
63
  transport: ChatTransport;
64
64
  config?: ChatConfig;
65
65
  initialSessionId?: string;
66
+ /**
67
+ * Warm transcript rendered instantly on mount while the resume-path
68
+ * `loadHistory` revalidates in the background (see `useChat`). Pair with
69
+ * `initialSessionId`: the host caches the last-seen transcript per room and
70
+ * passes it here so returning to a conversation shows content immediately
71
+ * instead of a cold refetch Spinner. The server read remains the source of
72
+ * truth — this is a visual head-start, replaced by the loaded page.
73
+ */
74
+ initialMessages?: readonly ChatMessage[];
66
75
  autoCreateSession?: boolean;
67
76
  streaming?: boolean;
68
77
  /** Audio-trigger configuration. Off by default (no `sounds` map). */
@@ -110,6 +119,7 @@ export function ChatProvider({
110
119
  transport,
111
120
  config = {},
112
121
  initialSessionId,
122
+ initialMessages,
113
123
  autoCreateSession,
114
124
  streaming,
115
125
  audio,
@@ -136,6 +146,7 @@ export function ChatProvider({
136
146
  const chat = useChat({
137
147
  transport,
138
148
  initialSessionId,
149
+ initialMessages,
139
150
  autoCreateSession,
140
151
  streaming,
141
152
  debug,
@@ -0,0 +1,161 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // F5 Part 2a — the `initialMessages` warm-hydrate seam.
4
+ //
5
+ // Contract under test (see useChat bootstrap):
6
+ // 1. With `initialSessionId` + non-empty `initialMessages`, the engine renders
7
+ // the seed synchronously on first commit — `historyLoaded` is already true,
8
+ // so the host never paints a Spinner (which gates on `!historyLoaded`).
9
+ // 2. The resume-path `loadHistory` STILL runs and REPLACES the seed with the
10
+ // server's authoritative page (server = source of truth; no stale-read).
11
+ // 3. Empty `initialMessages` (or the create path) is a no-op — normal
12
+ // loading behaviour, seed ignored.
13
+ import { act, createElement } from 'react';
14
+ import { createRoot, type Root } from 'react-dom/client';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+
17
+ // React needs this global to accept act(...) outside a test-runner integration.
18
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ import { useChat, type UseChatReturn } from '../useChat';
21
+ import type { ChatMessage } from '../../types';
22
+ import type { ChatTransport } from '../../types/transport';
23
+ import type { HistoryPage, SessionInfo } from '../../types/session';
24
+
25
+ function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
26
+ return { id, role, content, createdAt: 1 };
27
+ }
28
+
29
+ /** Transport whose loadHistory resolves only when the test releases it — lets us
30
+ * observe the synchronous-seed render BEFORE the server page lands. */
31
+ function deferredTransport(page: HistoryPage): {
32
+ transport: ChatTransport;
33
+ resolveHistory: () => void;
34
+ historyCalls: () => number;
35
+ } {
36
+ let release!: () => void;
37
+ const gate = new Promise<void>((r) => {
38
+ release = r;
39
+ });
40
+ let calls = 0;
41
+ const transport: ChatTransport = {
42
+ async createSession(): Promise<SessionInfo> {
43
+ return { sessionId: 'new-session' };
44
+ },
45
+ async loadHistory(): Promise<HistoryPage> {
46
+ calls += 1;
47
+ await gate;
48
+ return page;
49
+ },
50
+ async *stream() {
51
+ /* unused */
52
+ },
53
+ async send(): Promise<ChatMessage> {
54
+ return msg('x', 'x', 'assistant');
55
+ },
56
+ async closeSession() {
57
+ /* unused */
58
+ },
59
+ };
60
+ return { transport, resolveHistory: release, historyCalls: () => calls };
61
+ }
62
+
63
+ describe('useChat — initialMessages warm-hydrate seam', () => {
64
+ let roots: Root[] = [];
65
+ beforeEach(() => {
66
+ roots = [];
67
+ });
68
+ afterEach(() => {
69
+ act(() => roots.forEach((r) => r.unmount()));
70
+ });
71
+
72
+ it('renders the seed synchronously (historyLoaded true, seed messages) on the resume path', async () => {
73
+ const seed = [msg('s1', 'cached hello'), msg('s2', 'cached reply', 'assistant')];
74
+ const { transport, historyCalls } = deferredTransport({
75
+ messages: [msg('h1', 'server hello')],
76
+ hasMore: false,
77
+ nextCursor: null,
78
+ });
79
+
80
+ const snaps: UseChatReturn[] = [];
81
+ const root = createRoot(document.createElement('div'));
82
+ roots.push(root);
83
+ await act(async () => {
84
+ root.render(createElement(function W() {
85
+ const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: seed });
86
+ snaps.push(chat);
87
+ return null;
88
+ }));
89
+ });
90
+
91
+ // The seed was committed and the load settled flag is true → no Spinner.
92
+ const seededSnap = snaps.find((s) => s.messages.length === 2 && s.historyLoaded);
93
+ expect(seededSnap, 'a snapshot with the 2 seed messages + historyLoaded=true').toBeDefined();
94
+ expect(seededSnap!.messages.map((m) => m.content)).toEqual(['cached hello', 'cached reply']);
95
+ expect(seededSnap!.sessionId).toBe('room-1');
96
+ // loadHistory was invoked (background revalidation is in flight, still gated).
97
+ expect(historyCalls()).toBe(1);
98
+ });
99
+
100
+ it('replaces the seed with the server page once loadHistory resolves', async () => {
101
+ const seed = [msg('s1', 'STALE cached')];
102
+ const serverPage: HistoryPage = {
103
+ messages: [msg('h1', 'fresh server a'), msg('h2', 'fresh server b', 'assistant')],
104
+ hasMore: false,
105
+ nextCursor: null,
106
+ };
107
+ const { transport, resolveHistory } = deferredTransport(serverPage);
108
+
109
+ const snaps: UseChatReturn[] = [];
110
+ const root = createRoot(document.createElement('div'));
111
+ roots.push(root);
112
+ await act(async () => {
113
+ root.render(createElement(function W() {
114
+ const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: seed });
115
+ snaps.push(chat);
116
+ return null;
117
+ }));
118
+ });
119
+ // Release the background load and let the reducer commit HISTORY_LOAD_DONE.
120
+ await act(async () => {
121
+ resolveHistory();
122
+ await Promise.resolve();
123
+ });
124
+
125
+ const last = snaps[snaps.length - 1];
126
+ expect(last.messages.map((m) => m.content)).toEqual(['fresh server a', 'fresh server b']);
127
+ expect(last.messages.some((m) => m.content === 'STALE cached')).toBe(false);
128
+ expect(last.historyLoaded).toBe(true);
129
+ });
130
+
131
+ it('ignores an empty initialMessages array (normal load path)', async () => {
132
+ const loadHistory = vi.fn(async (): Promise<HistoryPage> => ({
133
+ messages: [msg('h1', 'server only')],
134
+ hasMore: false,
135
+ nextCursor: null,
136
+ }));
137
+ const transport = {
138
+ createSession: async () => ({ sessionId: 'x' }),
139
+ loadHistory,
140
+ async *stream() {},
141
+ send: async () => msg('a', 'a', 'assistant'),
142
+ closeSession: async () => {},
143
+ } as unknown as ChatTransport;
144
+
145
+ const snaps: UseChatReturn[] = [];
146
+ const root = createRoot(document.createElement('div'));
147
+ roots.push(root);
148
+ await act(async () => {
149
+ root.render(createElement(function W() {
150
+ const chat = useChat({ transport, initialSessionId: 'room-1', initialMessages: [] });
151
+ snaps.push(chat);
152
+ return null;
153
+ }));
154
+ });
155
+
156
+ // Empty seed: the load still drives the transcript, no seed shortcut.
157
+ const last = snaps[snaps.length - 1];
158
+ expect(last.messages.map((m) => m.content)).toEqual(['server only']);
159
+ expect(loadHistory).toHaveBeenCalledTimes(1);
160
+ });
161
+ });
@@ -171,7 +171,12 @@ export function useRegisterComposer(handle: ComposerHandle): void {
171
171
  // custom composers (e.g. the TipTap MarkdownEditor wrapper).
172
172
  const getValue = handle.getValue;
173
173
  const setValue = handle.setValue;
174
+ // Forward `attachFiles` too — the native OS-drop bridge routes an
175
+ // intercepted file drop through `getActiveComposer().attachFiles`, so a
176
+ // custom composer (TipTap MarkdownEditor wrapper) that owns the active
177
+ // handle must carry it or native drops silently no-op.
178
+ const attachFiles = handle.attachFiles;
174
179
  useEffect(() => {
175
- return attachComposer({ focus, moveCursorToEnd, getValue, setValue });
176
- }, [focus, moveCursorToEnd, getValue, setValue]);
180
+ return attachComposer({ focus, moveCursorToEnd, getValue, setValue, attachFiles });
181
+ }, [focus, moveCursorToEnd, getValue, setValue, attachFiles]);
177
182
  }
@@ -25,6 +25,21 @@ import { resolveSendMetadata } from '../core/metadata';
25
25
  export interface UseChatConfig {
26
26
  transport: ChatTransport;
27
27
  initialSessionId?: string;
28
+ /**
29
+ * Warm transcript to render synchronously on mount, before the resume-path
30
+ * `loadHistory` round-trip resolves. When set alongside `initialSessionId`,
31
+ * the engine seeds these messages and flips `historyLoaded` true immediately
32
+ * — no blocking Spinner — then still runs `loadHistory` in the background and
33
+ * REPLACES the seed with the server's authoritative page (via the normal
34
+ * `HISTORY_LOAD_DONE`). This makes "hydrate instant, revalidate in background"
35
+ * a first-class engine capability: the host caches the last-seen transcript
36
+ * (e.g. a per-room store) and passes it here so returning to a conversation
37
+ * shows content immediately instead of a cold refetch. The server read stays
38
+ * the source of truth — the seed is a visual head-start, never the last word.
39
+ *
40
+ * Ignored on the create path (no `initialSessionId`) and when empty.
41
+ */
42
+ initialMessages?: readonly ChatMessage[];
28
43
  autoCreateSession?: boolean;
29
44
  streaming?: boolean;
30
45
  pageSize?: number;
@@ -128,9 +143,34 @@ export function useChat(config: UseChatConfig): UseChatReturn {
128
143
  return;
129
144
  }
130
145
 
146
+ // Warm-hydrate: on the resume path, if the host handed us a cached
147
+ // transcript, commit it synchronously so the very first paint shows content
148
+ // (historyLoaded flips true via SESSION_SET) — no blocking Spinner. The
149
+ // resume-path `loadHistory` below still runs and REPLACES this seed with the
150
+ // server's authoritative page (HISTORY_LOAD_DONE), so the server stays the
151
+ // source of truth; the seed is only a visual head-start.
152
+ const warmSeed =
153
+ config.initialSessionId &&
154
+ config.initialMessages !== undefined &&
155
+ config.initialMessages.length > 0
156
+ ? config.initialMessages
157
+ : null;
158
+ if (warmSeed) {
159
+ dispatch({
160
+ type: 'SESSION_SET',
161
+ sessionId: config.initialSessionId!,
162
+ // Copy to a mutable array — the reducer stores it as owned state and the
163
+ // caller's array may be a frozen cache entry.
164
+ messages: [...warmSeed],
165
+ });
166
+ }
167
+
131
168
  // Show "loading" state immediately so the UI doesn't look idle while we
132
- // wait for createSession / loadHistory to come back.
133
- if (config.initialSessionId || autoCreateSession) {
169
+ // wait for createSession / loadHistory to come back. Skipped when we just
170
+ // warm-seeded HISTORY_LOAD_START would set isLoading without clearing the
171
+ // seed, and the UI already has content to show; the background loadHistory
172
+ // reconciles without a spinner.
173
+ if (!warmSeed && (config.initialSessionId || autoCreateSession)) {
134
174
  dispatch({ type: 'HISTORY_LOAD_START' });
135
175
  }
136
176
 
@@ -130,6 +130,10 @@ function ImageLightboxProvider({
130
130
  initialIndex={index}
131
131
  lightbox
132
132
  autoFocus
133
+ // Open at 80% of the viewport so the image sits with a comfortable
134
+ // margin (Preview / Quick Look) instead of slamming edge-to-edge;
135
+ // zoom/pan still reach full size from there.
136
+ fitScale={0.8}
133
137
  onRequestClose={() => setOpen(false)}
134
138
  />
135
139
  )}
@@ -140,7 +140,26 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
140
140
  // ResizeObserver (to correct virtuoso's `followOutput:'auto'`, which
141
141
  // settles a few px short on the final streamed chunk) and any other
142
142
  // "land exactly at end" path.
143
+ //
144
+ // The INSTANT pin (smooth=false) — which the ResizeObserver fires on every
145
+ // last-bubble size change and on the send transition — writes `scrollTop`
146
+ // DIRECTLY rather than going through `scrollToIndex`. This is the same
147
+ // technique the initial-landing phase uses (see the land loop below) and for
148
+ // the same reason: `scrollToIndex` re-enters virtuoso's initial-location
149
+ // SEEK, which on a list whose new last item is still being measured (exactly
150
+ // the send moment, when the empty assistant placeholder just mounted) can
151
+ // momentarily reflow/blank rows — visible as a twitch. A direct scrollTop
152
+ // write is an ordinary scroll to virtuoso, never a seek, so it lands cleanly.
153
+ // The smooth path (user tapped "jump to latest") keeps `scrollToIndex` — a
154
+ // seek is fine there because it's a deliberate one-shot, not a per-frame pin.
143
155
  const pinToBottom = useCallback((smooth = false) => {
156
+ if (!smooth) {
157
+ const el = scrollerRef.current;
158
+ if (el != null && el !== window && el instanceof HTMLElement) {
159
+ el.scrollTop = el.scrollHeight - el.clientHeight;
160
+ return;
161
+ }
162
+ }
144
163
  virtuosoRef.current?.scrollToIndex({
145
164
  index: 'LAST',
146
165
  align: 'end',
@@ -277,9 +296,26 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
277
296
  // otherwise the call lands on the previous height and clips the new
278
297
  // message under the composer. The initial-mount effect above handles
279
298
  // first paint, so we skip until it has run.
299
+ //
300
+ // JITTER on send (live incident 2026-07-05: "чат дёргается при отправке"):
301
+ // a send appends TWO items a tick apart — the user bubble, then the empty
302
+ // assistant placeholder (STREAM_START). When the user is already at the
303
+ // bottom (the common case — they just sent), the last-item ResizeObserver's
304
+ // `pinToBottom` INSTANTLY lands them on the placeholder as it mounts/grows.
305
+ // A SECOND, `smooth` `scrollToIndex('LAST')` fired from here then animates
306
+ // into that instant pin — the smooth glide gets yanked mid-flight and reads
307
+ // as a twitch. Two corrections:
308
+ // 1. When the user is following (`isFollowingRef`), the sticky corrector
309
+ // already owns the landing — DON'T also fire the anchor scroll. It is
310
+ // only needed to pull a SCROLLED-UP user back down on their own send.
311
+ // 2. Use `behavior:'auto'` (instant), not `'smooth'`: a send should snap,
312
+ // and an instant scroll can't be visibly interrupted by the pin.
280
313
  useEffect(() => {
281
314
  if (scrollAnchorId == null) return;
282
315
  if (!didInitialScrollRef.current) return;
316
+ // At-bottom users are landed by the sticky corrector; re-issuing a scroll
317
+ // here only collides with it. Skip — the pin has it covered.
318
+ if (isFollowingRef.current) return;
283
319
  let raf1 = 0;
284
320
  let raf2 = 0;
285
321
  raf1 = requestAnimationFrame(() => {
@@ -288,7 +324,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
288
324
  index: 'LAST',
289
325
  align: 'end',
290
326
  offset: BOTTOM_GAP_PX,
291
- behavior: 'smooth',
327
+ behavior: 'auto',
292
328
  });
293
329
  });
294
330
  });
@@ -136,8 +136,18 @@ export interface ChatRootProps {
136
136
  */
137
137
  transport?: ChatTransport;
138
138
  config?: ChatConfig;
139
- /** Session wiring — pre-existing id to attach, auto-create toggle. */
140
- session?: { initialId?: string; autoCreate?: boolean };
139
+ /**
140
+ * Session wiring pre-existing id to attach, auto-create toggle, and an
141
+ * optional warm transcript. `initialMessages` (with `initialId`) renders the
142
+ * cached conversation instantly on mount while the server history revalidates
143
+ * in the background — see `ChatProvider` / `useChat`. The host owns the cache
144
+ * (e.g. a per-room store); the server load stays the source of truth.
145
+ */
146
+ session?: {
147
+ initialId?: string;
148
+ autoCreate?: boolean;
149
+ initialMessages?: readonly ChatMessage[];
150
+ };
141
151
  streaming?: boolean;
142
152
  /** Audio-trigger configuration. Off by default (no `sounds` map). */
143
153
  audio?: ChatAudioConfig;
@@ -210,6 +220,7 @@ export function ChatRoot(props: ChatRootProps) {
210
220
  transport={transport}
211
221
  config={config}
212
222
  initialSessionId={session?.initialId}
223
+ initialMessages={session?.initialMessages}
213
224
  autoCreateSession={session?.autoCreate}
214
225
  streaming={streaming}
215
226
  audio={audio}
@@ -22,6 +22,13 @@ export interface ComposerHandle {
22
22
  * and final transcripts through this without owning a controlled
23
23
  * binding. */
24
24
  setValue?: (value: string) => void;
25
+ /** Attach already-resolved files through the composer's validated attach
26
+ * pipeline (size / type / count checks + upload + staging tray) — the
27
+ * same entry point the paperclip, paste and drag-drop use. Present only
28
+ * when the composer mounts the attach pipeline. A native shell (WKWebView /
29
+ * WebView2) that intercepts an OS file drop hands the bytes here so a
30
+ * native drop behaves exactly like an in-page one. */
31
+ attachFiles?: (files: File[]) => void;
25
32
  }
26
33
 
27
34
  /**
@@ -56,12 +56,22 @@ export function ImageViewer({
56
56
  lightbox = false,
57
57
  onRequestClose,
58
58
  autoFocus = false,
59
+ fitScale = 1,
59
60
  }: ImageViewerProps) {
60
61
  const t = useAppT();
61
62
 
62
63
  // `lightbox` implies the in-dialog presentation.
63
64
  const isLightbox = lightbox || inDialog;
64
65
 
66
+ // Cap the at-rest image size to `fitScale` of the viewport in the lightbox so
67
+ // it opens with a margin instead of edge-to-edge. Clamp to a sane range; a
68
+ // value of 1 (the default) is a no-op (falls back to the max-w/h-full class).
69
+ const fit = Math.min(1, Math.max(0.1, fitScale));
70
+ const fitStyle: React.CSSProperties | undefined =
71
+ isLightbox && fit < 1
72
+ ? { maxWidth: `${fit * 100}%`, maxHeight: `${fit * 100}%` }
73
+ : undefined;
74
+
65
75
  const [currentIndex, setCurrentIndex] = useState(() =>
66
76
  Math.max(0, Math.min(initialIndex, images.length - 1))
67
77
  );
@@ -80,6 +90,7 @@ export function ImageViewer({
80
90
  const [imgDecoded, setImgDecoded] = useState(false);
81
91
  const containerRef = useRef<HTMLDivElement>(null);
82
92
  const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
93
+ const imgRef = useRef<HTMLImageElement>(null);
83
94
 
84
95
  // Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
85
96
  // the controls visible; resting for CHROME_IDLE_MS fades them out.
@@ -107,10 +118,25 @@ export function ImageViewer({
107
118
  src: current?.src,
108
119
  });
109
120
 
110
- // Reset per-source load flags whenever the source changes
121
+ // Reset per-source load flags whenever the source changes.
122
+ //
123
+ // Race guard (first-open stuck on "Loading…"): the `<img onLoad>` handler is
124
+ // the ONLY thing that clears `imgDecoded`, but the browser can finish
125
+ // decoding — and fire `load` — BEFORE React commits the element and attaches
126
+ // that handler (a same-origin blob/attachment URL that resolves synchronously,
127
+ // or an already-cached image). The event is then missed and the spinner hangs
128
+ // forever; reopening "fixes" it only because the fresh mount happens to win
129
+ // the race. So after resetting, synchronously reconcile against the live img:
130
+ // if it's already `complete` with real pixels, mark it decoded now instead of
131
+ // waiting for an onLoad that already fired.
111
132
  useEffect(() => {
112
133
  setLoadError(false);
113
- setImgDecoded(false);
134
+ const img = imgRef.current;
135
+ if (img && img.complete && img.naturalWidth > 0) {
136
+ setImgDecoded(true);
137
+ } else {
138
+ setImgDecoded(false);
139
+ }
114
140
  }, [src]);
115
141
 
116
142
  const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
@@ -344,12 +370,13 @@ export function ImageViewer({
344
370
  alt=""
345
371
  aria-hidden="true"
346
372
  className="absolute max-w-full max-h-full object-contain select-none"
347
- style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1 }}
373
+ style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1, ...fitStyle }}
348
374
  draggable={false}
349
375
  />
350
376
  )}
351
377
  {src && (
352
378
  <img
379
+ ref={imgRef}
353
380
  src={src}
354
381
  alt={current.file.name}
355
382
  className={cn(
@@ -363,6 +390,7 @@ export function ImageViewer({
363
390
  transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
364
391
  opacity:
365
392
  (useProgressiveLoading && !isFullyLoaded) || !imgDecoded ? 0 : 1,
393
+ ...fitStyle,
366
394
  }}
367
395
  draggable={false}
368
396
  onLoad={() => setImgDecoded(true)}
@@ -53,6 +53,15 @@ export interface ImageViewerProps {
53
53
  lightbox?: boolean;
54
54
  /** Close request from the lightbox chrome (Esc / close button). */
55
55
  onRequestClose?: () => void;
56
+ /**
57
+ * Fraction of the viewport the image may occupy at rest, in the LIGHTBOX
58
+ * presentation only (0 < fitScale ≤ 1). `1` (default) lets the image fill
59
+ * edge-to-edge; `0.8` caps it at 80% so it opens with a comfortable margin
60
+ * (Preview / Quick Look style) instead of slamming into the window edges.
61
+ * Zoom / pan still operate from this fitted size. Ignored in the embedded
62
+ * (non-lightbox) presentation.
63
+ */
64
+ fitScale?: number;
56
65
  /** Focus the viewer container on mount so its keyboard scope is active
57
66
  * immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
58
67
  * upstream when the parent wants a fresh focus per source change. */