@pmate/chat-sdk 0.4.1 → 0.5.1

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,10 +1,11 @@
1
- import { useId, useRef, useState } from "react";
1
+ import { useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { filterMentionCandidates, findMentionQuery, rebaseMentions } from "../mentions/model";
3
3
  /** Attach inputProps to a textarea, render MentionMenu with menuProps, and pass
4
4
  * getMentions() with the untrimmed value when sending. No business lookup is performed. */
5
5
  export function useMentions({ value, onChange, config, disabled = false }) {
6
6
  const id = useId();
7
7
  const inputRef = useRef(null);
8
+ const pendingSelection = useRef(null);
8
9
  const menuRef = useRef(null);
9
10
  const document = useRef({ text: value, mentions: [] });
10
11
  const [caret, setCaret] = useState(0);
@@ -17,6 +18,17 @@ export function useMentions({ value, onChange, config, disabled = false }) {
17
18
  const query = config && focused && !composing && !disabled && !dismissed ? findMentionQuery(value, caret) : null;
18
19
  const candidates = query ? filterMentionCandidates(config.candidates, query.query) : [];
19
20
  const index = Math.min(activeIndex, Math.max(0, candidates.length - 1));
21
+ // Set the caret as soon as the controlled value commits. A later animation
22
+ // frame can otherwise move it backwards after the user's next keystroke.
23
+ useLayoutEffect(() => {
24
+ const pending = pendingSelection.current;
25
+ if (!pending || pending.text !== value)
26
+ return;
27
+ pendingSelection.current = null;
28
+ inputRef.current?.focus();
29
+ inputRef.current?.setSelectionRange(pending.position, pending.position);
30
+ setCaret(pending.position);
31
+ }, [value]);
20
32
  function select(candidate) {
21
33
  if (!query)
22
34
  return;
@@ -27,13 +39,8 @@ export function useMentions({ value, onChange, config, disabled = false }) {
27
39
  mentions: [...rebaseMentions(value, next, mentions), { id: candidate.id, name: candidate.name, start: query.start, end: query.start + insertion.length }].sort((a, b) => a.start - b.start),
28
40
  };
29
41
  setDismissed(true);
42
+ pendingSelection.current = { text: next, position: query.start + insertion.length + 1 };
30
43
  onChange(next);
31
- requestAnimationFrame(() => {
32
- const position = query.start + insertion.length + 1;
33
- inputRef.current?.focus();
34
- inputRef.current?.setSelectionRange(position, position);
35
- setCaret(position);
36
- });
37
44
  }
38
45
  function updateSelection(event) {
39
46
  inputRef.current = event.currentTarget;
@@ -1,5 +1,5 @@
1
1
  import { type ChatRuntimeMessage } from "./protocol.js";
2
- export type ChatActivityState = "thinking" | "searching" | "reading" | "typing" | "idle";
2
+ export type ChatActivityState = "working" | "typing" | "idle";
3
3
  export type ChatActivityBody = {
4
4
  kind: "activity";
5
5
  appId: string;
@@ -7,6 +7,9 @@ export type ChatActivityBody = {
7
7
  activityId: string;
8
8
  seq: number;
9
9
  state: ChatActivityState;
10
+ /** Open operation identifier, e.g. thinking, search, read. Unknown phases remain displayable. */
11
+ phase?: string;
12
+ replyTo?: string;
10
13
  ttlMs: number;
11
14
  /** Short public operation label, never private model reasoning. */
12
15
  detail?: string;
@@ -22,6 +25,7 @@ export type ActivityTarget = {
22
25
  to: string;
23
26
  threadKind?: "dm" | "group";
24
27
  recipients?: string[];
28
+ replyTo?: string;
25
29
  };
26
30
  export declare const ACTIVITY_TTL_MS = 15000;
27
31
  export declare function createActivityMessage(input: ActivityTarget & {
@@ -30,6 +34,7 @@ export declare function createActivityMessage(input: ActivityTarget & {
30
34
  activityId: string;
31
35
  seq: number;
32
36
  state: ChatActivityState;
37
+ phase?: string;
33
38
  detail?: string;
34
39
  }): ChatRuntimeMessage;
35
40
  export declare function parseChatActivity(message: ChatRuntimeMessage, appId: string, profileId: string, now?: number): ChatActivity | null;
@@ -45,23 +50,28 @@ export declare class ChatActivityStore {
45
50
  clear(): void;
46
51
  private emit;
47
52
  }
53
+ export type ChatActivityUpdate = {
54
+ state: Exclude<ChatActivityState, "idle">;
55
+ phase?: string;
56
+ detail?: string;
57
+ };
48
58
  /** Coalesced, serialized, best-effort progress; transport failures never reject the chat task. */
49
59
  export declare class ChatActivityPublisher {
50
60
  private readonly send;
51
61
  private readonly onError;
52
62
  private seq;
53
63
  private stopped;
54
- private state;
55
- private detail;
64
+ private current;
56
65
  private pending;
57
66
  private sending;
58
67
  private timer;
59
68
  constructor(send: (value: {
60
69
  state: ChatActivityState;
70
+ phase?: string;
61
71
  detail?: string;
62
72
  seq: number;
63
73
  }) => Promise<unknown>, onError?: (error: unknown) => void);
64
- update(state: Exclude<ChatActivityState, "idle">, detail?: string): void;
74
+ update(value: ChatActivityUpdate): void;
65
75
  stop(): Promise<void>;
66
76
  private enqueue;
67
77
  private flush;
@@ -3,6 +3,8 @@ import { dmThreadId, groupThreadId } from "./thread.js";
3
3
  export const ACTIVITY_TTL_MS = 15_000;
4
4
  export function createActivityMessage(input) {
5
5
  const body = { kind: "activity", appId: input.appId, threadKind: input.threadKind ?? "dm", activityId: input.activityId, seq: input.seq, state: input.state, ttlMs: ACTIVITY_TTL_MS,
6
+ ...(input.phase ? { phase: input.phase } : {}),
7
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
6
8
  ...(input.detail ? { detail: Array.from(input.detail).slice(0, 120).join("") } : {}) };
7
9
  const message = createRuntimeMsg(input.from, input.to, ChatMsgOp.CHAT, body);
8
10
  if (body.threadKind === "group")
@@ -16,7 +18,9 @@ export function parseChatActivity(message, appId, profileId, now = Date.now()) {
16
18
  || (body.threadKind === "dm" ? message.to !== profileId : !message.recipients?.includes(profileId))
17
19
  || typeof body.activityId !== "string" || !body.activityId || body.activityId.length > 128
18
20
  || !Number.isSafeInteger(body.seq) || body.seq < 0
19
- || !["thinking", "searching", "reading", "typing", "idle"].includes(body.state ?? "")
21
+ || !["working", "typing", "idle"].includes(body.state ?? "")
22
+ || (body.phase !== undefined && (typeof body.phase !== "string" || !/^[a-z][a-z0-9._-]{0,63}$/.test(body.phase)))
23
+ || (body.replyTo !== undefined && (typeof body.replyTo !== "string" || !body.replyTo || body.replyTo.length > 128))
20
24
  || !Number.isFinite(body.ttlMs) || body.ttlMs < 1000 || body.ttlMs > ACTIVITY_TTL_MS
21
25
  || !Number.isFinite(message.t) || message.t > now + ACTIVITY_TTL_MS || message.t + body.ttlMs <= now
22
26
  || (body.detail !== undefined && (typeof body.detail !== "string" || Array.from(body.detail).length > 120)))
@@ -52,7 +56,7 @@ export class ChatActivityStore {
52
56
  return;
53
57
  let changed = false;
54
58
  for (const [key, record] of this.records) {
55
- if (record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && record.updatedAt <= message.t && record.state !== "idle") {
59
+ if (record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && (record.replyTo && body.content?.replyTo ? record.replyTo === body.content.replyTo : record.updatedAt <= message.t) && record.state !== "idle") {
56
60
  this.records.set(key, { ...record, state: "idle" });
57
61
  changed = true;
58
62
  }
@@ -76,33 +80,31 @@ export class ChatActivityPublisher {
76
80
  onError;
77
81
  seq = 0;
78
82
  stopped = false;
79
- state = "thinking";
80
- detail;
83
+ current = { state: "working" };
81
84
  pending;
82
85
  sending;
83
86
  timer;
84
87
  constructor(send, onError = () => { }) {
85
88
  this.send = send;
86
89
  this.onError = onError;
87
- this.timer = setInterval(() => this.enqueue(this.state, this.detail), 5000);
90
+ this.timer = setInterval(() => this.enqueue(this.current), 5000);
88
91
  }
89
- update(state, detail) {
92
+ update(value) {
90
93
  if (this.stopped)
91
94
  return;
92
- this.state = state;
93
- this.detail = detail;
94
- this.enqueue(state, detail);
95
+ this.current = value;
96
+ this.enqueue(value);
95
97
  }
96
98
  async stop() {
97
99
  if (!this.stopped) {
98
100
  this.stopped = true;
99
101
  clearInterval(this.timer);
100
- this.enqueue("idle");
102
+ this.enqueue({ state: "idle" });
101
103
  }
102
104
  await this.sending;
103
105
  }
104
- enqueue(state, detail) {
105
- this.pending = { state, detail };
106
+ enqueue(value) {
107
+ this.pending = value;
106
108
  if (!this.sending)
107
109
  this.sending = this.flush().finally(() => { this.sending = undefined; });
108
110
  }
@@ -1,4 +1,4 @@
1
- import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState } from "./activity.js";
1
+ import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState, type ChatActivityUpdate } from "./activity.js";
2
2
  import type { ChatMention } from "../mentions/model";
3
3
  import { type BotLoginSession } from "./botAuth.js";
4
4
  import { type PmateBotKeyFile } from "./botKey.js";
@@ -34,9 +34,10 @@ export declare class ChatClient {
34
34
  activityId: string;
35
35
  seq: number;
36
36
  state: ChatActivityState;
37
+ phase?: string;
37
38
  detail?: string;
38
39
  }): Promise<ChatRuntimeMessage>;
39
- startActivity(target: ActivityTarget, state?: Exclude<ChatActivityState, "idle">): ChatActivityPublisher;
40
+ startActivity(target: ActivityTarget, initial?: ChatActivityUpdate): ChatActivityPublisher;
40
41
  requestFriend(input: {
41
42
  to: string;
42
43
  message?: string;
@@ -74,13 +74,13 @@ export class ChatClient {
74
74
  await this.sendAndWaitForAck(message);
75
75
  return message;
76
76
  }
77
- startActivity(target, state = "typing") {
77
+ startActivity(target, initial = { state: "typing" }) {
78
78
  const activityId = createRuntimeMsg(this.profileId(), target.to, ChatMsgOp.CHAT, null).hash;
79
79
  const publisher = new ChatActivityPublisher(value => this.sendActivity({ ...target, activityId, ...value }), error => this.options.onActivityError?.(error));
80
80
  this.publishers.add(publisher);
81
81
  const stop = publisher.stop.bind(publisher);
82
82
  publisher.stop = async () => { await stop(); this.publishers.delete(publisher); };
83
- publisher.update(state);
83
+ publisher.update(initial);
84
84
  return publisher;
85
85
  }
86
86
  async requestFriend(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",