@pmate/chat-sdk 0.12.0-next.70 → 0.12.0-next.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -289,3 +289,21 @@ The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
289
289
  identities are stored separately without rosters; repeated registrations share
290
290
  one write, concurrent lookups use bulk reads, and a concurrent memory-hit burst
291
291
  shares one fresh authority read without retaining authorization across bursts. Scope withdrawal retires both.
292
+
293
+
294
+ ## Slash command suggestions
295
+
296
+ ### Background
297
+
298
+ Host applications know which commands are available in a conversation. The shared desktop/mobile inputs now display that catalog while typing a leading `/`, without coupling the SDK to a particular bot or executing commands from the picker.
299
+
300
+ ```tsx
301
+ <ChatInputDesktop commands={{ commands: [
302
+ { name: "review", description: "Review code", usage: "PR URL or branch" },
303
+ { name: "qa", description: "Run independent tests", usage: "PR URL" },
304
+ ] }} mentions={groupMembers} onSend={sendMessage} />
305
+ ```
306
+
307
+ The same prop is supported by `ChatInputMobile`. Names omit the leading slash. Arrow keys navigate, Enter/Tab inserts a command and a space, Escape closes; touch selection edits the draft only. Command arguments and existing member mentions are preserved. IME composition, URLs and slashes in ordinary body text do not trigger command execution. Omit `commands` for unsupported conversations.
308
+
309
+ Host-owned textareas can use `useComposerSuggestions({ value, onChange, mentions, commands, disabled })`, spread `inputProps`, render `MentionMenu` with `menuProps` and `CommandMenu` with `commandMenuProps`, and send `getMentions()` with the untrimmed value.
@@ -0,0 +1,20 @@
1
+ /** Host-owned command catalog. Selecting a suggestion edits the draft only. */
2
+ export type ChatCommand = {
3
+ name: string;
4
+ description: string;
5
+ usage?: string;
6
+ };
7
+ export type ChatCommandConfig = {
8
+ commands: ChatCommand[];
9
+ label?: string;
10
+ emptyText?: string;
11
+ };
12
+ export declare function commandQuery(value: string, caret: number): {
13
+ query: string;
14
+ end: number;
15
+ } | null;
16
+ export declare function filterCommands(commands: ChatCommand[], query: string): ChatCommand[];
17
+ export declare function completeCommand(value: string, end: number, command: ChatCommand): {
18
+ text: string;
19
+ caret: number;
20
+ };
@@ -0,0 +1,12 @@
1
+ export function commandQuery(value, caret) {
2
+ const match = /^\/([^\s/]*)(?=\s|$)/.exec(value);
3
+ return match && caret >= 1 && caret <= match[0].length ? { query: value.slice(1, caret).toLowerCase(), end: match[0].length } : null;
4
+ }
5
+ export function filterCommands(commands, query) {
6
+ return commands.filter(c => /^[a-z][a-z0-9-]*$/.test(c.name) && c.name.toLowerCase().startsWith(query));
7
+ }
8
+ export function completeCommand(value, end, command) {
9
+ const prefix = `/${command.name}`, suffix = value.slice(end);
10
+ const text = prefix + (suffix || " ");
11
+ return { text, caret: prefix.length + 1 };
12
+ }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { handleImagePaste } from "../ux/imagePaste";
3
- import { useMentions } from "../hooks/useMentions";
3
+ import { useComposerSuggestions } from "../hooks/useComposerSuggestions";
4
+ import { CommandMenu } from "./CommandMenu";
4
5
  import { MentionMenu } from "./MentionMenu";
5
6
  import React from "react";
6
7
  import { Provider, createStore } from "jotai";
@@ -25,8 +26,8 @@ function DesktopInner(props) {
25
26
  const tokens = mergeChatInputTokens(props.tokens);
26
27
  const state = useChatInputState();
27
28
  const attachments = useAttachmentState();
28
- const mentions = useMentions({
29
- value: state.text, config: props.mentions, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
29
+ const mentions = useComposerSuggestions({
30
+ value: state.text, mentions: props.mentions, commands: props.commands, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
30
31
  onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
31
32
  });
32
33
  const [isEmojiOpen, setIsEmojiOpen] = useState(false);
@@ -186,7 +187,7 @@ function DesktopInner(props) {
186
187
  alignSelf: "center",
187
188
  transition: `color ${tokens.motion.stateTransition}, transform ${tokens.motion.pressFeedback}`,
188
189
  };
189
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
190
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsx(CommandMenu, { ...mentions.commandMenuProps, tokens: tokens }), _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
190
191
  const file = event.target.files?.[0];
191
192
  if (file) {
192
193
  void handleImageFile(file);
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { handleImagePaste } from "../ux/imagePaste";
3
- import { useMentions } from "../hooks/useMentions";
3
+ import { useComposerSuggestions } from "../hooks/useComposerSuggestions";
4
+ import { CommandMenu } from "./CommandMenu";
4
5
  import { MentionMenu } from "./MentionMenu";
5
6
  import React from "react";
6
7
  import { Provider, createStore } from "jotai";
@@ -26,8 +27,8 @@ function MobileInner(props) {
26
27
  const tokens = mergeChatInputTokens(props.tokens);
27
28
  const state = useChatInputState();
28
29
  const attachments = useAttachmentState();
29
- const mentions = useMentions({
30
- value: state.text, config: props.mentions, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
30
+ const mentions = useComposerSuggestions({
31
+ value: state.text, mentions: props.mentions, commands: props.commands, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
31
32
  onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
32
33
  });
33
34
  const [recordingChoice, setRecordingChoice] = useState("send");
@@ -267,7 +268,7 @@ function MobileInner(props) {
267
268
  userSelect: "none",
268
269
  WebkitUserSelect: "none",
269
270
  WebkitTouchCallout: "none",
270
- }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), isRecordingOverlayVisible ? (_jsxs("div", { style: {
271
+ }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsx(CommandMenu, { ...mentions.commandMenuProps, tokens: tokens }), isRecordingOverlayVisible ? (_jsxs("div", { style: {
271
272
  position: "fixed",
272
273
  left: "50%",
273
274
  bottom: "calc(72px + env(safe-area-inset-bottom, 0px))",
@@ -0,0 +1,15 @@
1
+ import type { RefObject } from "react";
2
+ import type { ChatCommand } from "../commands";
3
+ import type { ChatInputTokens, DeepPartial } from "../types/chatInput";
4
+ export declare function CommandMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }: {
5
+ id: string;
6
+ open: boolean;
7
+ candidates: ChatCommand[];
8
+ activeIndex: number;
9
+ onSelect: (c: ChatCommand) => void;
10
+ onActiveIndex: (i: number) => void;
11
+ menuRef: RefObject<HTMLDivElement | null>;
12
+ label: string;
13
+ emptyText: string;
14
+ tokens?: DeepPartial<ChatInputTokens>;
15
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,10 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useEffect } from "react";
3
+ import { mergeChatInputTokens } from "../tokens/defaultTokens";
4
+ export function CommandMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }) {
5
+ const tokens = mergeChatInputTokens(overrides);
6
+ useEffect(() => { menuRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }); }, [activeIndex, open]);
7
+ if (!open)
8
+ return null;
9
+ return _jsx("div", { id: id, ref: menuRef, role: "listbox", "aria-label": label, style: { position: "absolute", bottom: "calc(100% + 8px)", left: 0, width: "min(420px, 100%)", maxHeight: "min(320px, 40dvh)", overflowY: "auto", overscrollBehavior: "contain", zIndex: 20, boxSizing: "border-box", padding: 6, borderRadius: tokens.radius.inputSurface, border: `1px solid ${tokens.color.borderDefault}`, background: tokens.color.surfaceInputFocus, color: tokens.color.textPrimary, boxShadow: tokens.shadow.inputElevated, fontFamily: tokens.typography.familyBase }, children: candidates.length ? candidates.map((c, i) => _jsxs("button", { id: `${id}-${i}`, type: "button", role: "option", "aria-selected": i === activeIndex, tabIndex: -1, onMouseDown: e => e.preventDefault(), onMouseMove: () => onActiveIndex(i), onClick: () => onSelect(c), style: { display: "block", width: "100%", minHeight: 48, padding: "9px 12px", border: 0, borderRadius: 8, textAlign: "left", cursor: "pointer", color: "inherit", background: i === activeIndex ? `color-mix(in srgb, ${tokens.color.brandPrimary} 14%, ${tokens.color.surfaceInputFocus})` : "transparent" }, children: [_jsxs("span", { style: { display: "block", overflowWrap: "anywhere", fontSize: tokens.typography.inputText.size }, children: [_jsxs("strong", { style: { color: tokens.color.brandPrimary }, children: ["/", c.name] }), c.usage ? _jsx("span", { style: { marginLeft: 8, fontSize: 12, color: tokens.color.textSecondary }, children: c.usage }) : null] }), _jsx("small", { style: { display: "block", marginTop: 3, color: tokens.color.textSecondary }, children: c.description })] }, c.name)) : _jsx("div", { role: "status", style: { padding: 12, color: tokens.color.textSecondary }, children: emptyText }) });
10
+ }
@@ -0,0 +1,50 @@
1
+ import type { ChangeEvent, KeyboardEvent, SyntheticEvent } from "react";
2
+ import { type ChatCommand, type ChatCommandConfig } from "../commands";
3
+ import type { MentionConfig } from "../mentions/model";
4
+ /** One input contract for host-owned composers, SDK inputs, @ mentions and / commands. */
5
+ export declare function useComposerSuggestions({ value, onChange, mentions: config, commands, disabled }: {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ mentions?: MentionConfig;
9
+ commands?: ChatCommandConfig;
10
+ disabled?: boolean;
11
+ }): {
12
+ getMentions: () => import("..").ChatMention[];
13
+ menuProps: {
14
+ open: boolean;
15
+ id: string;
16
+ candidates: import("..").MentionCandidate[];
17
+ activeIndex: number;
18
+ onSelect: (candidate: import("..").MentionCandidate) => void;
19
+ onActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
20
+ menuRef: import("react").RefObject<HTMLDivElement | null>;
21
+ label: string;
22
+ emptyText: string;
23
+ };
24
+ commandMenuProps: {
25
+ id: string;
26
+ open: boolean;
27
+ candidates: ChatCommand[];
28
+ activeIndex: number;
29
+ onSelect: (command: ChatCommand) => void;
30
+ onActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
31
+ menuRef: import("react").RefObject<HTMLDivElement | null>;
32
+ label: string;
33
+ emptyText: string;
34
+ };
35
+ inputProps: {
36
+ onChange(event: ChangeEvent<HTMLTextAreaElement>): void;
37
+ onSelect(event: SyntheticEvent<HTMLTextAreaElement>): void;
38
+ onFocus(event: SyntheticEvent<HTMLTextAreaElement>): void;
39
+ onBlur(event: React.FocusEvent<HTMLTextAreaElement>): void;
40
+ onCompositionStart(): void;
41
+ onCompositionEnd(event: SyntheticEvent<HTMLTextAreaElement>): void;
42
+ onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void;
43
+ role?: "combobox" | undefined;
44
+ "aria-autocomplete"?: "list" | undefined;
45
+ "aria-haspopup"?: "listbox" | undefined;
46
+ "aria-expanded"?: boolean | undefined;
47
+ "aria-controls"?: string | undefined;
48
+ "aria-activedescendant"?: string | undefined;
49
+ };
50
+ };
@@ -0,0 +1,70 @@
1
+ import { useId, useLayoutEffect, useRef, useState } from "react";
2
+ import { useMentions } from "./useMentions";
3
+ import { commandQuery, completeCommand, filterCommands } from "../commands";
4
+ /** One input contract for host-owned composers, SDK inputs, @ mentions and / commands. */
5
+ export function useComposerSuggestions({ value, onChange, mentions: config, commands, disabled = false }) {
6
+ const mentions = useMentions({ value, onChange, config, disabled });
7
+ const id = useId(), inputRef = useRef(null), menuRef = useRef(null);
8
+ const pending = useRef(null);
9
+ const [caret, setCaret] = useState(0), [focused, setFocused] = useState(false), [composing, setComposing] = useState(false);
10
+ const [dismissed, setDismissed] = useState(false), [selected, setSelected] = useState(0), [collapsed, setCollapsed] = useState(true);
11
+ const query = commands?.commands.length && focused && collapsed && !composing && !disabled && !dismissed ? commandQuery(value, caret) : null;
12
+ const candidates = query ? filterCommands(commands.commands, query.query) : [];
13
+ const index = Math.min(selected, Math.max(0, candidates.length - 1));
14
+ useLayoutEffect(() => {
15
+ if (pending.current?.text !== value)
16
+ return;
17
+ const position = pending.current.caret;
18
+ pending.current = null;
19
+ inputRef.current?.focus({ preventScroll: true });
20
+ inputRef.current?.setSelectionRange(position, position);
21
+ setCaret(position);
22
+ }, [value]);
23
+ function observe(event) {
24
+ inputRef.current = event.currentTarget;
25
+ setCaret(event.currentTarget.selectionStart);
26
+ setCollapsed(event.currentTarget.selectionStart === event.currentTarget.selectionEnd);
27
+ }
28
+ function select(command) {
29
+ if (!query)
30
+ return;
31
+ const next = completeCommand(value, query.end, command);
32
+ pending.current = next;
33
+ setDismissed(true);
34
+ onChange(next.text);
35
+ }
36
+ return {
37
+ getMentions: mentions.getMentions,
38
+ menuProps: { ...mentions.menuProps, open: mentions.menuProps.open && !query },
39
+ commandMenuProps: { id, open: Boolean(query), candidates, activeIndex: index, onSelect: select, onActiveIndex: setSelected, menuRef, label: commands?.label ?? "选择命令", emptyText: commands?.emptyText ?? "没有匹配的命令" },
40
+ inputProps: {
41
+ ...mentions.inputProps,
42
+ ...(commands ? { role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": Boolean(query || mentions.menuProps.open), "aria-controls": query ? id : mentions.inputProps["aria-controls"], "aria-activedescendant": query && candidates[index] ? `${id}-${index}` : mentions.inputProps["aria-activedescendant"] } : {}),
43
+ onChange(event) { observe(event); setDismissed(false); setSelected(0); mentions.inputProps.onChange(event); },
44
+ onSelect(event) { observe(event); mentions.inputProps.onSelect(event); },
45
+ onFocus(event) { observe(event); setFocused(true); mentions.inputProps.onFocus(event); },
46
+ onBlur(event) { if (!menuRef.current?.contains(event.relatedTarget))
47
+ setFocused(false); mentions.inputProps.onBlur(event); },
48
+ onCompositionStart() { setComposing(true); mentions.inputProps.onCompositionStart(); },
49
+ onCompositionEnd(event) { setComposing(false); setDismissed(false); observe(event); mentions.inputProps.onCompositionEnd(event); },
50
+ onKeyDown(event) {
51
+ if (query && !event.nativeEvent.isComposing && event.keyCode !== 229) {
52
+ if (event.key === "Escape") {
53
+ event.preventDefault();
54
+ setDismissed(true);
55
+ }
56
+ else if (candidates.length && ["ArrowDown", "ArrowUp"].includes(event.key)) {
57
+ event.preventDefault();
58
+ setSelected((index + (event.key === "ArrowDown" ? 1 : candidates.length - 1)) % candidates.length);
59
+ }
60
+ else if (candidates[index] && ["Enter", "Tab"].includes(event.key) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
61
+ event.preventDefault();
62
+ select(candidates[index]);
63
+ }
64
+ }
65
+ if (!event.defaultPrevented)
66
+ mentions.inputProps.onKeyDown(event);
67
+ },
68
+ },
69
+ };
70
+ }
package/lib/index.d.ts CHANGED
@@ -43,3 +43,6 @@ export * from "./hooks/useImageAttachments";
43
43
  export * from "./components/ImageAttachmentTray";
44
44
  export { createChat, type Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type ReadOptions, type ReadersQuery, type GroupChanges } from "./chat.js";
45
45
  export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
46
+ export * from "./commands";
47
+ export * from "./components/CommandMenu";
48
+ export * from "./hooks/useComposerSuggestions";
package/lib/index.js CHANGED
@@ -43,3 +43,6 @@ export * from "./hooks/useImageAttachments";
43
43
  export * from "./components/ImageAttachmentTray";
44
44
  export { createChat, ChatError } from "./chat.js";
45
45
  export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
46
+ export * from "./commands";
47
+ export * from "./components/CommandMenu";
48
+ export * from "./hooks/useComposerSuggestions";
@@ -1,3 +1,4 @@
1
+ import type { ChatCommandConfig } from "../commands";
1
2
  import type { MentionConfig, ChatMention } from "../mentions/model";
2
3
  import type { ClipboardEventHandler, ComponentType, DragEventHandler, ReactNode } from "react";
3
4
  import type { ChatAttachment } from "./attachment";
@@ -121,6 +122,8 @@ export type ChatImageConfig = {
121
122
  uploadImage?: (file: File) => Promise<ChatImageUploadResult>;
122
123
  };
123
124
  export type ChatInputBaseProps = {
125
+ /** Host-provided slash commands; omit to disable command suggestions. Selection never sends. */
126
+ commands?: ChatCommandConfig;
124
127
  /** Group members supplied by the host; omit for ordinary text input. */
125
128
  mentions?: MentionConfig;
126
129
  /** Use a taller composer with actions in a bottom toolbar. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.70",
3
+ "version": "0.12.0-next.71",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",