@sybilion/uilib 1.3.33 → 1.3.36

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.
Files changed (54) hide show
  1. package/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +2 -2
  2. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.js +18 -4
  3. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.js +39 -52
  4. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPrompt.styl.js +2 -2
  5. package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptComposer.js +25 -0
  6. package/dist/esm/components/ui/Chat/ChatPrompt/chatPromptDoc.js +17 -0
  7. package/dist/esm/components/ui/Chat/ChatPrompt/useChatPromptEditor.js +163 -0
  8. package/dist/esm/components/ui/Tooltip/Tooltip.styl.js +1 -1
  9. package/dist/esm/index.js +2 -0
  10. package/dist/esm/tiptap/slash-mention/SlashSuggestionList.js +53 -0
  11. package/dist/esm/tiptap/slash-mention/SlashSuggestionList.styl.js +7 -0
  12. package/dist/esm/tiptap/slash-mention/createSlashMentionExtension.js +151 -0
  13. package/dist/esm/tiptap/slash-mention/defaultChatSlashItems.js +12 -0
  14. package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +3 -0
  15. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.d.ts +1 -1
  16. package/dist/esm/types/src/components/ui/Chat/ChatChrome/ChatChrome.types.d.ts +5 -0
  17. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.d.ts +1 -1
  18. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.d.ts +6 -0
  19. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.d.ts +13 -0
  20. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/chatPromptDoc.d.ts +3 -0
  21. package/dist/esm/types/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.d.ts +20 -0
  22. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -0
  23. package/dist/esm/types/src/components/ui/Input/Input.d.ts +1 -1
  24. package/dist/esm/types/src/docs/pages/ChatSlashCommandsPage.d.ts +1 -0
  25. package/dist/esm/types/src/index.d.ts +1 -0
  26. package/dist/esm/types/src/tiptap/slash-mention/SlashSuggestionList.d.ts +12 -0
  27. package/dist/esm/types/src/tiptap/slash-mention/createSlashMentionExtension.d.ts +21 -0
  28. package/dist/esm/types/src/tiptap/slash-mention/defaultChatSlashItems.d.ts +4 -0
  29. package/dist/esm/types/src/tiptap/slash-mention/index.d.ts +5 -0
  30. package/dist/esm/types/src/tiptap/slash-mention/types.d.ts +25 -0
  31. package/package.json +15 -1
  32. package/src/components/ui/Chat/Chat.types.ts +4 -0
  33. package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -0
  34. package/src/components/ui/Chat/ChatChrome/ChatChrome.types.ts +5 -0
  35. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.helpers.ts +43 -0
  36. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl +33 -5
  37. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.styl.d.ts +2 -1
  38. package/src/components/ui/Chat/ChatPrompt/ChatPrompt.tsx +50 -106
  39. package/src/components/ui/Chat/ChatPrompt/ChatPromptComposer.tsx +93 -0
  40. package/src/components/ui/Chat/ChatPrompt/chatPromptDoc.ts +18 -0
  41. package/src/components/ui/Chat/ChatPrompt/useChatPromptEditor.ts +214 -0
  42. package/src/components/ui/Chat/index.ts +1 -0
  43. package/src/components/ui/Tooltip/Tooltip.styl +1 -0
  44. package/src/docs/pages/ChatSlashCommandsPage.tsx +139 -0
  45. package/src/docs/pages/TooltipPage.tsx +1 -1
  46. package/src/docs/registry.ts +6 -0
  47. package/src/index.ts +1 -0
  48. package/src/tiptap/slash-mention/SlashSuggestionList.styl +48 -0
  49. package/src/tiptap/slash-mention/SlashSuggestionList.styl.d.ts +11 -0
  50. package/src/tiptap/slash-mention/SlashSuggestionList.tsx +109 -0
  51. package/src/tiptap/slash-mention/createSlashMentionExtension.ts +217 -0
  52. package/src/tiptap/slash-mention/defaultChatSlashItems.ts +18 -0
  53. package/src/tiptap/slash-mention/index.ts +16 -0
  54. package/src/tiptap/slash-mention/types.ts +29 -0
@@ -0,0 +1,12 @@
1
+ /** Empty default: pass `slashCommandItems` from the app to enable `/` palette. */
2
+ const DEFAULT_CHAT_SLASH_ITEMS = [];
3
+ function filterSlashItems(items, query) {
4
+ const q = query.trim().toLowerCase();
5
+ if (!q)
6
+ return items;
7
+ return items.filter(item => item.id.toLowerCase().includes(q) ||
8
+ item.label.toLowerCase().includes(q) ||
9
+ (item.description?.toLowerCase().includes(q) ?? false));
10
+ }
11
+
12
+ export { DEFAULT_CHAT_SLASH_ITEMS, filterSlashItems };
@@ -1,4 +1,5 @@
1
1
  import type { HTMLAttributes, ReactNode } from 'react';
2
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
2
3
  import type { PresetScriptGraph } from './ChatMessage/presetScript';
3
4
  export declare enum MessageRole {
4
5
  USER = "user",
@@ -81,6 +82,8 @@ export interface ChatPromptProps {
81
82
  attachmentAccept?: string;
82
83
  /** Called when the user picks files via the attach button. */
83
84
  onAttachmentFiles?: (files: File[]) => void;
85
+ /** Slash menu (`/`); omit or pass empty to disable Mention trigger (no default items). */
86
+ slashCommandItems?: SlashCommandItem[];
84
87
  }
85
88
  export interface ChatMessageProps {
86
89
  role: MessageRole;
@@ -1,2 +1,2 @@
1
1
  import type { ChatChromeProps } from './ChatChrome.types';
2
- export declare function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, scriptContinueLabel, onScriptContinue, renderMessageChart, showBranchActionsRow, showSyntheticBranchButtons, unusedBranchKeys, isScriptComplete, onGenerateDashboard, generatingDashboard, onGenerateDashboardClick, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, }: ChatChromeProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatChrome({ showResizeHandle, resizeHandle, onClose, isEmpty, renderPresets, messages, onQuickReply, suppressedQuickReplyKeys, isLoading, scriptContinueLabel, onScriptContinue, renderMessageChart, showBranchActionsRow, showSyntheticBranchButtons, unusedBranchKeys, isScriptComplete, onGenerateDashboard, generatingDashboard, onGenerateDashboardClick, showInlinePresets, isLastMessageFromUser, scrollRef, effectiveScopeId, onPromptSubmit, onChatDeleted, promptPrefill, footerClassName, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, promptPlaceholder, }: ChatChromeProps): import("react/jsx-runtime").JSX.Element;
@@ -2,6 +2,7 @@ import type { RefObject } from 'react';
2
2
  import type { ChatAttachmentDropItem, Message } from '#uilib/components/ui/Chat/Chat.types';
3
3
  import type { ChatEmptyStateProps } from '#uilib/components/ui/Chat/ChatEmptyState/ChatEmptyState.types';
4
4
  import type { ChatPresetsLayout } from '#uilib/components/ui/Chat/ChatPresets';
5
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
5
6
  import type { ScrollRef } from '@homecode/ui';
6
7
  export type ChatChromeResizeHandleConfig = {
7
8
  isActive: boolean;
@@ -48,4 +49,8 @@ export interface ChatChromeProps {
48
49
  allowPdfAttachments?: boolean;
49
50
  /** Optional hook when attachments are sent with a message. */
50
51
  onAttachmentsDropped?: (items: ChatAttachmentDropItem[]) => void | Promise<void>;
52
+ /** Slash menu (`/`), forwarded to `Chat.Prompt`; omit or pass empty list to disable slash palette. */
53
+ slashCommandItems?: SlashCommandItem[];
54
+ /** Composer placeholder forwarded to `Chat.Prompt`. */
55
+ promptPlaceholder?: string;
51
56
  }
@@ -1,2 +1,2 @@
1
1
  import type { ChatPromptProps } from '../Chat.types';
2
- export declare function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, attachments, onRemoveAttachment, disabled, attachmentAccept, onAttachmentFiles, }: ChatPromptProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ChatPrompt({ onSubmit, placeholder, className, footer, prefillMessage, slashCommandItems, attachments, onRemoveAttachment, disabled, attachmentAccept, onAttachmentFiles, }: ChatPromptProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,12 @@
1
1
  /** Keep in sync with `ChatPrompt.styl` `INPUT_MAX_HEIGHT` / `min-height`. */
2
2
  export declare const PROMPT_INPUT_MAX_HEIGHT_PX = 200;
3
3
  export declare const PROMPT_INPUT_MIN_HEIGHT_PX = 40;
4
+ /** ProseMirror `view.dom` exists only after TipTap mounts `<EditorContent />`; accessing `.view` can throw earlier. */
5
+ export declare function chatPromptSafeEditorDom(editor: {
6
+ readonly isDestroyed?: boolean;
7
+ } | null | undefined): HTMLElement | null;
4
8
  export declare function chatPromptTextareaFloorPx(cs: CSSStyleDeclaration): number;
5
9
  /** Autosizing textarea inside flex shells: empty → floor only; typed → collapse measure + clamp. */
6
10
  export declare function syncChatPromptTextareaHeight(el: HTMLTextAreaElement, message: string): void;
11
+ /** Same sizing rules for TipTap `.ProseMirror` root (`text` mirrors doc plain text length for empty-state). */
12
+ export declare function syncChatPromptComposerHeight(el: HTMLElement, text: string): void;
@@ -0,0 +1,13 @@
1
+ import { type KeyboardEvent as ReactKeyboardEvent } from 'react';
2
+ import type { Editor } from '@tiptap/core';
3
+ import type { ChatAttachmentDropItem } from '../Chat.types';
4
+ export type ChatPromptComposerProps = {
5
+ editor: Editor;
6
+ disabled: boolean;
7
+ trimmedMessage: string;
8
+ attachments: ChatAttachmentDropItem[];
9
+ attachmentAccept?: string;
10
+ onAttachmentFiles?: (files: File[]) => void;
11
+ onComposerKeyDown: (event: ReactKeyboardEvent) => void;
12
+ };
13
+ export declare function ChatPromptComposer({ editor, disabled, trimmedMessage, attachments, attachmentAccept, onAttachmentFiles, onComposerKeyDown, }: ChatPromptComposerProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,3 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+ export declare const CHAT_PROMPT_EMPTY_DOC: JSONContent;
3
+ export declare function chatPromptParagraphDoc(text: string): JSONContent;
@@ -0,0 +1,20 @@
1
+ import { type KeyboardEvent as ReactKeyboardEvent } from 'react';
2
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
3
+ import type { Editor } from '@tiptap/core';
4
+ export type UseChatPromptEditorOptions = {
5
+ disabled: boolean;
6
+ placeholder?: string;
7
+ slashCommandItems?: SlashCommandItem[];
8
+ prefillMessage?: string | null;
9
+ /** Staged attachment count — Enter-to-send when text empty but files present. */
10
+ attachmentsCount?: number;
11
+ /** Called when user presses Enter to send (after guards). */
12
+ onEnterSubmit: () => void;
13
+ };
14
+ export type UseChatPromptEditorResult = {
15
+ editor: Editor | null;
16
+ trimmedMessage: string;
17
+ resetAfterSend: () => void;
18
+ handleComposerKeyDown: (event: ReactKeyboardEvent) => void;
19
+ };
20
+ export declare function useChatPromptEditor({ disabled, placeholder, slashCommandItems, prefillMessage, attachmentsCount, onEnterSubmit, }: UseChatPromptEditorOptions): UseChatPromptEditorResult;
@@ -15,4 +15,5 @@ export { ChatPresets } from './ChatPresets';
15
15
  export type { ChatEmptyStateConfig, ChatEmptyStateContext, ChatEmptyStateProps, } from './ChatEmptyState/ChatEmptyState.types';
16
16
  export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserTextFileAttachment, } from './Chat.types';
17
17
  export { MessageRole } from './Chat.types';
18
+ export type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
18
19
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -3,7 +3,7 @@ declare const Input: React.ForwardRefExoticComponent<(Omit<Omit<React.DetailedHT
3
3
  type?: React.ComponentProps<"input">["type"];
4
4
  size?: "sm" | "md" | "lg";
5
5
  variant?: "default" | "clean";
6
- }, "ref"> | Omit<Omit<React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "size" | "type"> & {
6
+ }, "ref"> | Omit<Omit<React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>, "type" | "size"> & {
7
7
  type: "textarea";
8
8
  size?: "sm" | "md" | "lg";
9
9
  variant?: "default" | "clean";
@@ -0,0 +1 @@
1
+ export default function ChatSlashCommandsPage(): import("react/jsx-runtime").JSX.Element;
@@ -6,6 +6,7 @@ export { DEFAULT_THEME_ACTIVE_COLOR } from './docs/lib/theme';
6
6
  export * from './sybilion-auth';
7
7
  export * from './types/sybilionDatasetSnapshots';
8
8
  export * from './contexts/chat-context';
9
+ export * from './tiptap/slash-mention';
9
10
  export * from './types/chat-api.types';
10
11
  export * from './components/ui/AnalysesSelector';
11
12
  export * from './components/ui/AnalysisLineIcon';
@@ -0,0 +1,12 @@
1
+ import type { RefObject } from 'react';
2
+ import type { SlashCommandItem } from './types';
3
+ export type SlashSuggestionListHandle = {
4
+ onKeyboardEvent: (event: KeyboardEvent) => boolean;
5
+ };
6
+ /** Props forwarded from Tiptap suggestion renderer + our wiring. */
7
+ export type SlashSuggestionListProps = {
8
+ items: SlashCommandItem[];
9
+ command: (item: SlashCommandItem) => void;
10
+ listHandleRef: RefObject<SlashSuggestionListHandle | null>;
11
+ };
12
+ export declare function SlashSuggestionList(props: SlashSuggestionListProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import type { MutableRefObject } from 'react';
2
+ import type { SuggestionProps } from '@tiptap/suggestion';
3
+ import { type SlashSuggestionListHandle } from './SlashSuggestionList';
4
+ import type { CreateSlashMentionExtensionOptions, SlashCommandItem } from './types';
5
+ export declare function slashMentionSuggestionRender(uiRef: MutableRefObject<SlashSuggestionListHandle | null>): {
6
+ onStart?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
7
+ onUpdate?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
8
+ onExit?: (props: SuggestionProps<SlashCommandItem, SlashCommandItem>) => void;
9
+ onKeyDown?: (p: {
10
+ view: unknown;
11
+ event: KeyboardEvent;
12
+ range: {
13
+ from: number;
14
+ to: number;
15
+ };
16
+ }) => boolean;
17
+ };
18
+ export type CreateSlashMentionExtensionConfiguredOptions = CreateSlashMentionExtensionOptions & {
19
+ onSuggestionUiActiveChange?: (active: boolean) => void;
20
+ };
21
+ export declare function createSlashMentionExtension({ items: resolvedItems, slashChar, pluginKey, onItemCommand, onSuggestionUiActiveChange, }: CreateSlashMentionExtensionConfiguredOptions): import("@tiptap/core").Node<import("@tiptap/extension-mention").MentionOptions<any, import("@tiptap/extension-mention").MentionNodeAttrs>, any>;
@@ -0,0 +1,4 @@
1
+ import type { SlashCommandItem } from './types';
2
+ /** Empty default: pass `slashCommandItems` from the app to enable `/` palette. */
3
+ export declare const DEFAULT_CHAT_SLASH_ITEMS: SlashCommandItem[];
4
+ export declare function filterSlashItems(items: SlashCommandItem[], query: string): SlashCommandItem[];
@@ -0,0 +1,5 @@
1
+ export type { SlashCommandItem, SlashOnItemCommand, SlashItemCommandContext, CreateSlashMentionExtensionOptions, } from './types';
2
+ export { DEFAULT_CHAT_SLASH_ITEMS, filterSlashItems, } from './defaultChatSlashItems';
3
+ export { createSlashMentionExtension, slashMentionSuggestionRender, } from './createSlashMentionExtension';
4
+ export type { CreateSlashMentionExtensionConfiguredOptions } from './createSlashMentionExtension';
5
+ export type { SlashSuggestionListHandle } from './SlashSuggestionList';
@@ -0,0 +1,25 @@
1
+ import type { Editor, Range } from '@tiptap/core';
2
+ export type SlashCommandItem = {
3
+ id: string;
4
+ label: string;
5
+ description?: string;
6
+ };
7
+ export type SlashItemCommandContext = {
8
+ editor: Editor;
9
+ range: Range;
10
+ item: SlashCommandItem;
11
+ };
12
+ /**
13
+ * If provided, run before default mention insertion. Return true to skip inserting a mention node.
14
+ */
15
+ export type SlashOnItemCommand = (ctx: SlashItemCommandContext) => boolean;
16
+ export type CreateSlashMentionExtensionOptions = {
17
+ /** Items shown in the slash menu (filtered by query after `/`). */
18
+ items: SlashCommandItem[];
19
+ /** Trigger character; use `/` for slash commands. */
20
+ slashChar?: string;
21
+ /** Optional custom plugin key when multiple slash editors exist. */
22
+ pluginKey?: import('@tiptap/pm/state').PluginKey;
23
+ /** Custom handler (e.g. insert a block node instead of a mention). */
24
+ onItemCommand?: SlashOnItemCommand;
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.33",
3
+ "version": "1.3.36",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -117,6 +117,13 @@
117
117
  "peerDependencies": {
118
118
  "@auth0/auth0-react": "^2.3.1",
119
119
  "@sybilion/platform-sdk": ">=0.0.1",
120
+ "@tiptap/core": "^3.22.0",
121
+ "@tiptap/extension-mention": "^3.22.0",
122
+ "@tiptap/extensions": "^3.22.0",
123
+ "@tiptap/pm": "^3.22.0",
124
+ "@tiptap/react": "^3.22.0",
125
+ "@tiptap/starter-kit": "^3.22.0",
126
+ "@tiptap/suggestion": "^3.22.0",
120
127
  "react": ">=18.0.0",
121
128
  "react-dom": ">=18.0.0",
122
129
  "react-router-dom": ">=6.0.0",
@@ -151,6 +158,13 @@
151
158
  "@testing-library/dom": "^10.4.1",
152
159
  "@testing-library/jest-dom": "^6.5.0",
153
160
  "@testing-library/react": "^16.0.1",
161
+ "@tiptap/core": "^3.22.1",
162
+ "@tiptap/extension-mention": "^3.22.1",
163
+ "@tiptap/extensions": "^3.22.1",
164
+ "@tiptap/pm": "^3.22.1",
165
+ "@tiptap/react": "^3.22.1",
166
+ "@tiptap/starter-kit": "^3.22.1",
167
+ "@tiptap/suggestion": "^3.22.1",
154
168
  "@trivago/prettier-plugin-sort-imports": "^6.0.2",
155
169
  "@types/jest": "^29.4.0",
156
170
  "@types/node": "^18.14.0",
@@ -1,5 +1,7 @@
1
1
  import type { HTMLAttributes, ReactNode } from 'react';
2
2
 
3
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
4
+
3
5
  import type { PresetScriptGraph } from './ChatMessage/presetScript';
4
6
 
5
7
  export enum MessageRole {
@@ -92,6 +94,8 @@ export interface ChatPromptProps {
92
94
  attachmentAccept?: string;
93
95
  /** Called when the user picks files via the attach button. */
94
96
  onAttachmentFiles?: (files: File[]) => void;
97
+ /** Slash menu (`/`); omit or pass empty to disable Mention trigger (no default items). */
98
+ slashCommandItems?: SlashCommandItem[];
95
99
  }
96
100
 
97
101
  export interface ChatMessageProps {
@@ -56,6 +56,8 @@ export function ChatChrome({
56
56
  allowedAttachments,
57
57
  allowPdfAttachments = false,
58
58
  onAttachmentsDropped,
59
+ slashCommandItems,
60
+ promptPlaceholder,
59
61
  }: ChatChromeProps) {
60
62
  const filteredAllowedAttachments = useMemo(
61
63
  () => filterToTextAttachments(allowedAttachments),
@@ -287,6 +289,8 @@ export function ChatChrome({
287
289
  attachments={pendingAttachments}
288
290
  onRemoveAttachment={handleRemoveAttachment}
289
291
  prefillMessage={promptPrefill ?? undefined}
292
+ placeholder={promptPlaceholder}
293
+ slashCommandItems={slashCommandItems}
290
294
  attachmentAccept={
291
295
  attachmentsDropzoneEnabled ? attachmentAccept : undefined
292
296
  }
@@ -6,6 +6,7 @@ import type {
6
6
  } from '#uilib/components/ui/Chat/Chat.types';
7
7
  import type { ChatEmptyStateProps } from '#uilib/components/ui/Chat/ChatEmptyState/ChatEmptyState.types';
8
8
  import type { ChatPresetsLayout } from '#uilib/components/ui/Chat/ChatPresets';
9
+ import type { SlashCommandItem } from '#uilib/tiptap/slash-mention/types';
9
10
  import type { ScrollRef } from '@homecode/ui';
10
11
 
11
12
  export type ChatChromeResizeHandleConfig = {
@@ -62,4 +63,8 @@ export interface ChatChromeProps {
62
63
  onAttachmentsDropped?: (
63
64
  items: ChatAttachmentDropItem[],
64
65
  ) => void | Promise<void>;
66
+ /** Slash menu (`/`), forwarded to `Chat.Prompt`; omit or pass empty list to disable slash palette. */
67
+ slashCommandItems?: SlashCommandItem[];
68
+ /** Composer placeholder forwarded to `Chat.Prompt`. */
69
+ promptPlaceholder?: string;
65
70
  }
@@ -2,6 +2,20 @@
2
2
  export const PROMPT_INPUT_MAX_HEIGHT_PX = 200;
3
3
  export const PROMPT_INPUT_MIN_HEIGHT_PX = 40;
4
4
 
5
+ /** ProseMirror `view.dom` exists only after TipTap mounts `<EditorContent />`; accessing `.view` can throw earlier. */
6
+ export function chatPromptSafeEditorDom(
7
+ editor: { readonly isDestroyed?: boolean } | null | undefined,
8
+ ): HTMLElement | null {
9
+ if (!editor || editor.isDestroyed) return null;
10
+ try {
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const dom = (editor as any).view?.dom as unknown;
13
+ return dom instanceof HTMLElement ? dom : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
5
19
  export function chatPromptTextareaFloorPx(cs: CSSStyleDeclaration): number {
6
20
  const padY =
7
21
  (Number.parseFloat(cs.paddingTop) || 0) +
@@ -41,3 +55,32 @@ export function syncChatPromptTextareaHeight(
41
55
  el.style.overflowY =
42
56
  contentHeight > PROMPT_INPUT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
43
57
  }
58
+
59
+ /** Same sizing rules for TipTap `.ProseMirror` root (`text` mirrors doc plain text length for empty-state). */
60
+ export function syncChatPromptComposerHeight(
61
+ el: HTMLElement,
62
+ text: string,
63
+ ): void {
64
+ const floor = chatPromptTextareaFloorPx(getComputedStyle(el));
65
+
66
+ el.style.overflowY = 'hidden';
67
+ let contentHeight: number;
68
+
69
+ const empty = text.trim() === '';
70
+
71
+ if (empty) {
72
+ contentHeight = floor;
73
+ } else {
74
+ el.style.height = '0';
75
+ contentHeight = el.scrollHeight;
76
+ }
77
+
78
+ const h = Math.min(
79
+ Math.max(contentHeight, floor),
80
+ PROMPT_INPUT_MAX_HEIGHT_PX,
81
+ );
82
+
83
+ el.style.height = `${h}px`;
84
+ el.style.overflowY =
85
+ contentHeight > PROMPT_INPUT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
86
+ }
@@ -26,20 +26,48 @@ INPUT_MAX_HEIGHT = 200px
26
26
  flex-shrink 0
27
27
  align-self flex-end
28
28
 
29
- .input
29
+ .editorWrap
30
30
  flex 1
31
31
  min-width 0
32
+ align-self stretch
33
+
34
+ .editorMount
35
+ display flex
36
+ flex 1
37
+ flex-direction column
38
+ min-width 0
32
39
  min-height 40px
33
40
  max-height INPUT_MAX_HEIGHT
34
- resize none
35
- overflow-y auto
36
41
  border none
37
- padding var(--p-2) 0 0 !important
42
+ padding 0 !important
38
43
  border-radius 0 !important
39
44
  box-shadow none !important
45
+ background transparent
46
+
47
+ &:focus-within
48
+ box-shadow none !important
40
49
 
41
- &:focus
50
+ & :global(.ProseMirror)
51
+ outline none !important
52
+ border none !important
53
+ flex 1
54
+ padding var(--p-2) 0 0 !important
55
+ margin 0
56
+ white-space pre-wrap
57
+ word-break break-word
58
+ resize none !important
42
59
  box-shadow none !important
60
+ min-height 40px !important
61
+ max-height INPUT_MAX_HEIGHT !important
62
+ overflow-y auto !important
63
+ overflow-x hidden !important
64
+
65
+ & :global(.ProseMirror p.is-empty::before)
66
+ color var(--muted-foreground)
67
+ content attr(data-placeholder)
68
+ float left
69
+ height 0
70
+ pointer-events none
43
71
 
44
72
  .submitColumn
45
73
  display flex
@@ -5,8 +5,9 @@ interface CssExports {
5
5
  'attachmentItem': string;
6
6
  'attachments': string;
7
7
  'composer': string;
8
+ 'editorMount': string;
9
+ 'editorWrap': string;
8
10
  'fileInput': string;
9
- 'input': string;
10
11
  'root': string;
11
12
  'submitColumn': string;
12
13
  }
@@ -1,22 +1,11 @@
1
1
  import cn from 'classnames';
2
- import {
3
- ChangeEvent,
4
- FormEvent,
5
- useEffect,
6
- useLayoutEffect,
7
- useRef,
8
- useState,
9
- } from 'react';
2
+ import { FormEvent, useCallback, useRef } from 'react';
10
3
 
11
- import useEvent from '#uilib/hooks/useEvent';
12
- import { PaperclipIcon, SendHorizontalIcon } from 'lucide-react';
13
-
14
- import { Button } from '../../Button';
15
- import { Input } from '../../Input';
16
4
  import type { ChatPromptProps } from '../Chat.types';
17
- import { syncChatPromptTextareaHeight } from './ChatPrompt.helpers';
18
5
  import S from './ChatPrompt.styl';
19
6
  import { ChatPromptAttachments } from './ChatPromptAttachments';
7
+ import { ChatPromptComposer } from './ChatPromptComposer';
8
+ import { useChatPromptEditor } from './useChatPromptEditor';
20
9
 
21
10
  export function ChatPrompt({
22
11
  onSubmit,
@@ -24,117 +13,72 @@ export function ChatPrompt({
24
13
  className,
25
14
  footer,
26
15
  prefillMessage,
16
+ slashCommandItems,
27
17
  attachments = [],
28
18
  onRemoveAttachment,
29
19
  disabled = false,
30
20
  attachmentAccept,
31
21
  onAttachmentFiles,
32
22
  }: ChatPromptProps) {
33
- const [message, setMessage] = useState('');
34
- const inputRef = useRef<HTMLTextAreaElement>(null);
35
- const fileInputRef = useRef<HTMLInputElement>(null);
36
- const showAttachButton = Boolean(attachmentAccept && onAttachmentFiles);
37
-
38
- useLayoutEffect(() => {
39
- const el = inputRef.current;
40
- if (!el) return;
41
- syncChatPromptTextareaHeight(el, message);
42
- }, [message]);
23
+ const attachmentsCount = attachments.length;
43
24
 
44
- useEffect(() => {
45
- if (prefillMessage != null && prefillMessage !== '') {
46
- setMessage(prefillMessage);
47
- }
48
- }, [prefillMessage]);
25
+ const emitSubmitRef = useRef(() => {});
26
+ const { editor, trimmedMessage, resetAfterSend, handleComposerKeyDown } =
27
+ useChatPromptEditor({
28
+ disabled,
29
+ placeholder,
30
+ slashCommandItems,
31
+ prefillMessage,
32
+ attachmentsCount,
33
+ onEnterSubmit: () => emitSubmitRef.current(),
34
+ });
49
35
 
50
- const handleSubmit = (e: FormEvent | KeyboardEvent) => {
51
- const trimmedMessage = message.trim();
52
- const hasAttachments = attachments.length > 0;
36
+ const emitSubmitAndClear = useCallback(() => {
37
+ if (!editor) return;
38
+ if (!trimmedMessage && attachmentsCount === 0) return;
53
39
 
54
- if (trimmedMessage || hasAttachments) {
55
- e.preventDefault();
56
- onSubmit(trimmedMessage, hasAttachments ? attachments : undefined);
57
- setMessage('');
58
- }
59
- };
40
+ const msg = trimmedMessage;
41
+ resetAfterSend();
42
+ onSubmit(msg, attachmentsCount > 0 ? attachments : undefined);
43
+ }, [
44
+ attachments,
45
+ attachmentsCount,
46
+ editor,
47
+ onSubmit,
48
+ resetAfterSend,
49
+ trimmedMessage,
50
+ ]);
60
51
 
61
- const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
62
- const files = Array.from(e.target.files ?? []);
63
- e.target.value = '';
64
- if (files.length > 0) {
65
- onAttachmentFiles?.(files);
66
- }
67
- };
52
+ emitSubmitRef.current = emitSubmitAndClear;
68
53
 
69
- useEvent({
70
- event: 'keydown',
71
- callback: (e: KeyboardEvent) => {
72
- if (e.key === 'Enter' && !(e.metaKey || e.shiftKey || e.ctrlKey)) {
73
- e.preventDefault();
74
- handleSubmit(e);
75
- setMessage('');
76
- }
54
+ const handleSubmitForm = useCallback(
55
+ (e?: FormEvent) => {
56
+ e?.preventDefault();
57
+ emitSubmitAndClear();
77
58
  },
78
- });
59
+ [emitSubmitAndClear],
60
+ );
61
+
62
+ if (!editor) {
63
+ return null;
64
+ }
79
65
 
80
66
  return (
81
- <form onSubmit={handleSubmit} className={cn(S.root, className)}>
67
+ <form onSubmit={handleSubmitForm} className={cn(S.root, className)}>
82
68
  <ChatPromptAttachments
83
69
  attachments={attachments}
84
70
  onRemove={index => onRemoveAttachment?.(index)}
85
71
  disabled={disabled}
86
72
  />
87
- <div className={S.composer}>
88
- {showAttachButton ? (
89
- <>
90
- <input
91
- ref={fileInputRef}
92
- type="file"
93
- accept={attachmentAccept}
94
- multiple
95
- className={S.fileInput}
96
- disabled={disabled}
97
- onChange={handleFileInputChange}
98
- />
99
- <Button
100
- type="button"
101
- variant="ghost"
102
- icon
103
- size="sm"
104
- className={S.attachButton}
105
- aria-label="Attach file"
106
- disabled={disabled}
107
- onClick={e => {
108
- e.preventDefault();
109
- fileInputRef.current?.click();
110
- }}
111
- >
112
- <PaperclipIcon size={16} />
113
- </Button>
114
- </>
115
- ) : null}
116
-
117
- <Input
118
- ref={inputRef}
119
- type="textarea"
120
- rows={1}
121
- value={message}
122
- onChange={e => setMessage(e.target.value)}
123
- placeholder={placeholder || 'Type a message...'}
124
- className={cn(S.input)}
125
- />
126
-
127
- <div className={S.submitColumn}>
128
- <Button
129
- type="submit"
130
- size="sm"
131
- disabled={disabled || (!message.trim() && attachments.length === 0)}
132
- >
133
- <SendHorizontalIcon size={16} />
134
- </Button>
135
- </div>
136
- </div>
137
-
73
+ <ChatPromptComposer
74
+ editor={editor}
75
+ disabled={disabled}
76
+ trimmedMessage={trimmedMessage}
77
+ attachments={attachments}
78
+ attachmentAccept={attachmentAccept}
79
+ onAttachmentFiles={onAttachmentFiles}
80
+ onComposerKeyDown={handleComposerKeyDown}
81
+ />
138
82
  {footer}
139
83
  </form>
140
84
  );