@stapel/chat-react 0.1.0

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 (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +118 -0
  3. package/README.md +126 -0
  4. package/dist/api/chatApi.d.ts +49 -0
  5. package/dist/api/chatApi.d.ts.map +1 -0
  6. package/dist/api/chatApi.js +46 -0
  7. package/dist/api/chatApi.js.map +1 -0
  8. package/dist/api/extensions.d.ts +19 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +19 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/generated/schema.d.ts +547 -0
  13. package/dist/api/generated/schema.d.ts.map +1 -0
  14. package/dist/api/generated/schema.js +2 -0
  15. package/dist/api/generated/schema.js.map +1 -0
  16. package/dist/api/types.d.ts +75 -0
  17. package/dist/api/types.d.ts.map +1 -0
  18. package/dist/api/types.js +2 -0
  19. package/dist/api/types.js.map +1 -0
  20. package/dist/default/ConversationListPanel.d.ts +22 -0
  21. package/dist/default/ConversationListPanel.d.ts.map +1 -0
  22. package/dist/default/ConversationListPanel.js +49 -0
  23. package/dist/default/ConversationListPanel.js.map +1 -0
  24. package/dist/default/ConversationThreadPanel.d.ts +21 -0
  25. package/dist/default/ConversationThreadPanel.d.ts.map +1 -0
  26. package/dist/default/ConversationThreadPanel.js +51 -0
  27. package/dist/default/ConversationThreadPanel.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +8 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +26 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/StartChatButton.d.ts +17 -0
  33. package/dist/default/StartChatButton.d.ts.map +1 -0
  34. package/dist/default/StartChatButton.js +18 -0
  35. package/dist/default/StartChatButton.js.map +1 -0
  36. package/dist/default/index.d.ts +21 -0
  37. package/dist/default/index.d.ts.map +1 -0
  38. package/dist/default/index.js +18 -0
  39. package/dist/default/index.js.map +1 -0
  40. package/dist/flows/errors.d.ts +12 -0
  41. package/dist/flows/errors.d.ts.map +1 -0
  42. package/dist/flows/errors.js +13 -0
  43. package/dist/flows/errors.js.map +1 -0
  44. package/dist/flows/freshness.d.ts +69 -0
  45. package/dist/flows/freshness.d.ts.map +1 -0
  46. package/dist/flows/freshness.js +227 -0
  47. package/dist/flows/freshness.js.map +1 -0
  48. package/dist/flows/registry.d.ts +26 -0
  49. package/dist/flows/registry.d.ts.map +1 -0
  50. package/dist/flows/registry.js +24 -0
  51. package/dist/flows/registry.js.map +1 -0
  52. package/dist/headless/ChatProvider.d.ts +22 -0
  53. package/dist/headless/ChatProvider.d.ts.map +1 -0
  54. package/dist/headless/ChatProvider.js +18 -0
  55. package/dist/headless/ChatProvider.js.map +1 -0
  56. package/dist/headless/ConversationList.d.ts +51 -0
  57. package/dist/headless/ConversationList.d.ts.map +1 -0
  58. package/dist/headless/ConversationList.js +47 -0
  59. package/dist/headless/ConversationList.js.map +1 -0
  60. package/dist/headless/ConversationThread.d.ts +50 -0
  61. package/dist/headless/ConversationThread.d.ts.map +1 -0
  62. package/dist/headless/ConversationThread.js +64 -0
  63. package/dist/headless/ConversationThread.js.map +1 -0
  64. package/dist/headless/MessageComposer.d.ts +39 -0
  65. package/dist/headless/MessageComposer.d.ts.map +1 -0
  66. package/dist/headless/MessageComposer.js +45 -0
  67. package/dist/headless/MessageComposer.js.map +1 -0
  68. package/dist/headless/StartDirectChat.d.ts +40 -0
  69. package/dist/headless/StartDirectChat.d.ts.map +1 -0
  70. package/dist/headless/StartDirectChat.js +46 -0
  71. package/dist/headless/StartDirectChat.js.map +1 -0
  72. package/dist/i18n/errorsMap.d.ts +10 -0
  73. package/dist/i18n/errorsMap.d.ts.map +1 -0
  74. package/dist/i18n/errorsMap.js +20 -0
  75. package/dist/i18n/errorsMap.js.map +1 -0
  76. package/dist/i18n/es.d.ts +23 -0
  77. package/dist/i18n/es.d.ts.map +1 -0
  78. package/dist/i18n/es.js +75 -0
  79. package/dist/i18n/es.js.map +1 -0
  80. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.es.gen.js +58 -0
  83. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  84. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  85. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  86. package/dist/i18n/generated/errors.gen.js +183 -0
  87. package/dist/i18n/generated/errors.gen.js.map +1 -0
  88. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  89. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  90. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  91. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  92. package/dist/i18n/keys.d.ts +78 -0
  93. package/dist/i18n/keys.d.ts.map +1 -0
  94. package/dist/i18n/keys.js +130 -0
  95. package/dist/i18n/keys.js.map +1 -0
  96. package/dist/i18n/ru.d.ts +28 -0
  97. package/dist/i18n/ru.d.ts.map +1 -0
  98. package/dist/i18n/ru.js +80 -0
  99. package/dist/i18n/ru.js.map +1 -0
  100. package/dist/index.d.ts +61 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +55 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/model/context.d.ts +11 -0
  105. package/dist/model/context.d.ts.map +1 -0
  106. package/dist/model/context.js +16 -0
  107. package/dist/model/context.js.map +1 -0
  108. package/dist/model/limits.d.ts +12 -0
  109. package/dist/model/limits.d.ts.map +1 -0
  110. package/dist/model/limits.js +12 -0
  111. package/dist/model/limits.js.map +1 -0
  112. package/dist/model/mutations.d.ts +66 -0
  113. package/dist/model/mutations.d.ts.map +1 -0
  114. package/dist/model/mutations.js +156 -0
  115. package/dist/model/mutations.js.map +1 -0
  116. package/dist/model/queries.d.ts +33 -0
  117. package/dist/model/queries.d.ts.map +1 -0
  118. package/dist/model/queries.js +93 -0
  119. package/dist/model/queries.js.map +1 -0
  120. package/dist/model/queryKeys.d.ts +8 -0
  121. package/dist/model/queryKeys.d.ts.map +1 -0
  122. package/dist/model/queryKeys.js +26 -0
  123. package/dist/model/queryKeys.js.map +1 -0
  124. package/dist/model/readMarker.d.ts +25 -0
  125. package/dist/model/readMarker.d.ts.map +1 -0
  126. package/dist/model/readMarker.js +31 -0
  127. package/dist/model/readMarker.js.map +1 -0
  128. package/dist/model/runtime.d.ts +50 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +27 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/model/threadWindow.d.ts +95 -0
  133. package/dist/model/threadWindow.d.ts.map +1 -0
  134. package/dist/model/threadWindow.js +100 -0
  135. package/dist/model/threadWindow.js.map +1 -0
  136. package/dist/nav/manifest.d.ts +23 -0
  137. package/dist/nav/manifest.d.ts.map +1 -0
  138. package/dist/nav/manifest.js +15 -0
  139. package/dist/nav/manifest.js.map +1 -0
  140. package/dist/realtime/chatSocket.d.ts +93 -0
  141. package/dist/realtime/chatSocket.d.ts.map +1 -0
  142. package/dist/realtime/chatSocket.js +199 -0
  143. package/dist/realtime/chatSocket.js.map +1 -0
  144. package/dist/realtime/frames.d.ts +123 -0
  145. package/dist/realtime/frames.d.ts.map +1 -0
  146. package/dist/realtime/frames.js +123 -0
  147. package/dist/realtime/frames.js.map +1 -0
  148. package/dist/realtime/streams.d.ts +49 -0
  149. package/dist/realtime/streams.d.ts.map +1 -0
  150. package/dist/realtime/streams.js +52 -0
  151. package/dist/realtime/streams.js.map +1 -0
  152. package/llms.txt +89 -0
  153. package/manifest.json +733 -0
  154. package/nav-manifest.json +25 -0
  155. package/package.json +114 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/chatApi.ts +142 -0
  158. package/src/api/extensions.ts +18 -0
  159. package/src/api/generated/schema.ts +549 -0
  160. package/src/api/types.ts +87 -0
  161. package/src/default/ConversationListPanel.tsx +163 -0
  162. package/src/default/ConversationThreadPanel.tsx +245 -0
  163. package/src/default/ErrorAlert.tsx +43 -0
  164. package/src/default/StartChatButton.tsx +78 -0
  165. package/src/default/index.ts +20 -0
  166. package/src/flows/errors.ts +16 -0
  167. package/src/flows/freshness.ts +315 -0
  168. package/src/flows/registry.ts +36 -0
  169. package/src/headless/ChatProvider.tsx +23 -0
  170. package/src/headless/ConversationList.tsx +92 -0
  171. package/src/headless/ConversationThread.tsx +107 -0
  172. package/src/headless/MessageComposer.tsx +86 -0
  173. package/src/headless/StartDirectChat.tsx +75 -0
  174. package/src/i18n/errorsMap.ts +31 -0
  175. package/src/i18n/es.ts +95 -0
  176. package/src/i18n/generated/errors.es.gen.ts +64 -0
  177. package/src/i18n/generated/errors.gen.ts +214 -0
  178. package/src/i18n/generated/errors.json +408 -0
  179. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  180. package/src/i18n/keys.ts +152 -0
  181. package/src/i18n/ru.ts +98 -0
  182. package/src/index.ts +189 -0
  183. package/src/model/context.tsx +25 -0
  184. package/src/model/limits.ts +11 -0
  185. package/src/model/mutations.ts +208 -0
  186. package/src/model/queries.ts +120 -0
  187. package/src/model/queryKeys.ts +32 -0
  188. package/src/model/readMarker.ts +32 -0
  189. package/src/model/runtime.ts +90 -0
  190. package/src/model/threadWindow.ts +173 -0
  191. package/src/nav/manifest.ts +36 -0
  192. package/src/realtime/chatSocket.ts +304 -0
  193. package/src/realtime/frames.ts +229 -0
  194. package/src/realtime/streams.ts +85 -0
  195. package/tsconfig.json +26 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutations.d.ts","sourceRoot":"","sources":["../../src/model/mutations.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,iBAAiB,EAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAMjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AA4B1D,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,cAAc,EAAE,MAAM,GACrB,iBAAiB,CAAC,WAAW,EAAE,cAAc,EAAE,oBAAoB,CAAC,CAuBtE;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,cAAc,EAAE,MAAM,GACrB,iBAAiB,CAAC,MAAM,GAAG,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,CAsB1D;AAED,gDAAgD;AAChD,MAAM,WAAW,wBAAwB;IACvC,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,IAAI,iBAAiB,CACrD,YAAY,EACZ,cAAc,EACd,wBAAwB,CACzB,CAoBA;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,cAAc,EAAE,MAAM,EACtB,KAAK,GAAE,MAAoB,GAC1B,iBAAiB,CAAC,gBAAgB,GAAG,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,CAyBlE"}
@@ -0,0 +1,156 @@
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { useChatApi } from "./context.js";
3
+ import { chatQueryKeys } from "./queryKeys.js";
4
+ import { nextReadMarker } from "./readMarker.js";
5
+ import { THREAD_PAGE } from "./queries.js";
6
+ import { mergeMessage, mergeOlderPage } from "./threadWindow.js";
7
+ /**
8
+ * Write hooks (frontend-standard §2 — mutations invalidate on success).
9
+ *
10
+ * Sending is REST, always: the socket's `send` frame refuses with a
11
+ * socket-local code that has no i18n key and no remediation, while the HTTP
12
+ * path answers with the persisted row and a real error envelope. See
13
+ * `realtime/frames.ts`.
14
+ */
15
+ /** Fold a freshly-known message into the cached window, or re-read on a hole. */
16
+ function absorbMessage(queryClient, conversationId, message) {
17
+ const key = chatQueryKeys.thread(conversationId);
18
+ const window = queryClient.getQueryData(key);
19
+ if (!window)
20
+ return;
21
+ const merged = mergeMessage(window, message);
22
+ if (merged.gap) {
23
+ void queryClient.invalidateQueries({ queryKey: key });
24
+ return;
25
+ }
26
+ queryClient.setQueryData(key, merged.window);
27
+ }
28
+ /**
29
+ * Append a message to a thread. On success the persisted row (with its `seq`)
30
+ * is folded straight into the window, so the sender sees their own line
31
+ * without waiting for a poll — and the socket's fan-out copy of the same row
32
+ * is then a duplicate that `mergeMessage` drops.
33
+ *
34
+ * Attachments are NOT wired in this version (spec §4.5): the field exists on
35
+ * the wire, but shipping it would mean shipping CDN upload rights into chat,
36
+ * and a control that is visible but does nothing is worse than one that is
37
+ * absent.
38
+ */
39
+ export function useSendMessage(conversationId) {
40
+ const api = useChatApi();
41
+ const queryClient = useQueryClient();
42
+ const options = {
43
+ mutationFn: (vars) => api.sendMessage(conversationId, {
44
+ body: vars.body,
45
+ ...(vars.replyTo !== undefined ? { reply_to: vars.replyTo } : {}),
46
+ }),
47
+ onSuccess: (message) => {
48
+ absorbMessage(queryClient, conversationId, message);
49
+ // The list orders by `updated_at` and carries the unread badge; both
50
+ // just moved.
51
+ void queryClient.invalidateQueries({
52
+ queryKey: chatQueryKeys.conversations(),
53
+ });
54
+ },
55
+ };
56
+ return useMutation(options);
57
+ }
58
+ /**
59
+ * Advance the caller's read marker to `upto_seq`.
60
+ *
61
+ * Monotonic on the client as well as on the server: a candidate at or below
62
+ * what this client already reported resolves without a request
63
+ * (`nextReadMarker`). The reported value is remembered in the query cache —
64
+ * see `readMarker.ts` for why it cannot be read back off the wire.
65
+ */
66
+ export function useMarkRead(conversationId) {
67
+ const api = useChatApi();
68
+ const queryClient = useQueryClient();
69
+ const markerKey = chatQueryKeys.readMarker(conversationId);
70
+ const options = {
71
+ mutationFn: async (candidate) => {
72
+ const known = queryClient.getQueryData(markerKey);
73
+ const seq = nextReadMarker(known, candidate);
74
+ if (seq === null)
75
+ return null;
76
+ await api.markRead(conversationId, seq);
77
+ return seq;
78
+ },
79
+ onSuccess: (seq) => {
80
+ if (seq === null)
81
+ return;
82
+ queryClient.setQueryData(markerKey, seq);
83
+ // The badge is server-computed; re-read it rather than guessing.
84
+ void queryClient.invalidateQueries({
85
+ queryKey: chatQueryKeys.conversations(),
86
+ });
87
+ },
88
+ };
89
+ return useMutation(options);
90
+ }
91
+ /**
92
+ * Open the direct thread with someone, creating it only if it does not exist.
93
+ *
94
+ * The idempotency is the module's, not ours: a direct thread is keyed by an
95
+ * order-independent `direct_key` over the participant pair (namespaced by
96
+ * scope) under a unique constraint, and the create race is resolved by the
97
+ * constraint — the loser gets the winner's row (`MODULE.md`, "Direct
98
+ * idempotency"). So "message the seller" is safe to press twice, from two
99
+ * tabs, on two listings.
100
+ *
101
+ * TWO PEOPLE, ONE THREAD — INCLUDING ACROSS LISTINGS. The key is the pair,
102
+ * not the listing: a buyer who writes to the same seller about a second item
103
+ * lands in the same conversation. `CreateConversationRequest.scope_key` does
104
+ * not change that (the server ignores the field and resolves the scope
105
+ * itself — `api/extensions.ts`), so a host that wants the listing named must
106
+ * name it in the first message.
107
+ */
108
+ export function useStartDirectChat() {
109
+ const api = useChatApi();
110
+ const queryClient = useQueryClient();
111
+ const options = {
112
+ mutationFn: (vars) => api.createConversation("direct", [vars.userId]),
113
+ onSuccess: (conversation) => {
114
+ queryClient.setQueryData(chatQueryKeys.conversation(conversation.id), conversation);
115
+ void queryClient.invalidateQueries({
116
+ queryKey: chatQueryKeys.conversations(),
117
+ });
118
+ },
119
+ };
120
+ return useMutation(options);
121
+ }
122
+ /**
123
+ * Backfill one page of older history onto the front of the window
124
+ * (`direction=next` from the window's oldest seq — "next" is OLDER here,
125
+ * because the history is ordered `-seq`).
126
+ *
127
+ * A mutation rather than a second query: it is an action a person takes
128
+ * ("show me more"), it must not re-run on focus, and its result belongs in
129
+ * the ONE thread cache entry rather than in a second one that would then have
130
+ * to be reconciled with it.
131
+ */
132
+ export function useLoadOlderMessages(conversationId, limit = THREAD_PAGE) {
133
+ const api = useChatApi();
134
+ const queryClient = useQueryClient();
135
+ const key = chatQueryKeys.thread(conversationId);
136
+ const options = {
137
+ mutationFn: async () => {
138
+ const window = queryClient.getQueryData(key);
139
+ if (!window || !window.hasOlder || window.olderAnchor === null)
140
+ return null;
141
+ const page = await api.messages(conversationId, {
142
+ direction: "next",
143
+ anchor: window.olderAnchor,
144
+ limit,
145
+ });
146
+ return mergeOlderPage(window, page);
147
+ },
148
+ onSuccess: (window) => {
149
+ if (window === null)
150
+ return;
151
+ queryClient.setQueryData(key, window);
152
+ },
153
+ };
154
+ return useMutation(options);
155
+ }
156
+ //# sourceMappingURL=mutations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mutations.js","sourceRoot":"","sources":["../../src/model/mutations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAQpE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGjE;;;;;;;GAOG;AAEH,iFAAiF;AACjF,SAAS,aAAa,CACpB,WAAwB,EACxB,cAAsB,EACtB,OAAoB;IAEpB,MAAM,GAAG,GAAG,aAAa,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,WAAW,CAAC,YAAY,CAAmB,GAAG,CAAC,CAAC;IAC/D,IAAI,CAAC,MAAM;QAAE,OAAO;IACpB,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C,IAAI,MAAM,CAAC,GAAG,EAAE,CAAC;QACf,KAAK,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC;QACtD,OAAO;IACT,CAAC;IACD,WAAW,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/C,CAAC;AASD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAC5B,cAAsB;IAEtB,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,OAAO,GAIT;QACF,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CACnB,GAAG,CAAC,WAAW,CAAC,cAAc,EAAE;YAC9B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,GAAG,CAAC,IAAI,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAClE,CAAC;QACJ,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YACrB,aAAa,CAAC,WAAW,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;YACpD,qEAAqE;YACrE,cAAc;YACd,KAAK,WAAW,CAAC,iBAAiB,CAAC;gBACjC,QAAQ,EAAE,aAAa,CAAC,aAAa,EAAE;aACxC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;IACF,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CACzB,cAAsB;IAEtB,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,SAAS,GAAG,aAAa,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC;IAC3D,MAAM,OAAO,GAA8D;QACzE,UAAU,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE;YAC9B,MAAM,KAAK,GAAG,WAAW,CAAC,YAAY,CAAS,SAAS,CAAC,CAAC;YAC1D,MAAM,GAAG,GAAG,cAAc,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;YAC7C,IAAI,GAAG,KAAK,IAAI;gBAAE,OAAO,IAAI,CAAC;YAC9B,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;YACxC,OAAO,GAAG,CAAC;QACb,CAAC;QACD,SAAS,EAAE,CAAC,GAAG,EAAE,EAAE;YACjB,IAAI,GAAG,KAAK,IAAI;gBAAE,OAAO;YACzB,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;YACzC,iEAAiE;YACjE,KAAK,WAAW,CAAC,iBAAiB,CAAC;gBACjC,QAAQ,EAAE,aAAa,CAAC,aAAa,EAAE;aACxC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;IACF,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC;AAQD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB;IAKhC,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,OAAO,GAIT;QACF,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,kBAAkB,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACrE,SAAS,EAAE,CAAC,YAAY,EAAE,EAAE;YAC1B,WAAW,CAAC,YAAY,CACtB,aAAa,CAAC,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC,EAC3C,YAAY,CACb,CAAC;YACF,KAAK,WAAW,CAAC,iBAAiB,CAAC;gBACjC,QAAQ,EAAE,aAAa,CAAC,aAAa,EAAE;aACxC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;IACF,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB,CAClC,cAAsB,EACtB,QAAgB,WAAW;IAE3B,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,aAAa,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACjD,MAAM,OAAO,GAIT;QACF,UAAU,EAAE,KAAK,IAAI,EAAE;YACrB,MAAM,MAAM,GAAG,WAAW,CAAC,YAAY,CAAmB,GAAG,CAAC,CAAC;YAC/D,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,WAAW,KAAK,IAAI;gBAAE,OAAO,IAAI,CAAC;YAC5E,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE;gBAC9C,SAAS,EAAE,MAAM;gBACjB,MAAM,EAAE,MAAM,CAAC,WAAW;gBAC1B,KAAK;aACN,CAAC,CAAC;YACH,OAAO,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QACtC,CAAC;QACD,SAAS,EAAE,CAAC,MAAM,EAAE,EAAE;YACpB,IAAI,MAAM,KAAK,IAAI;gBAAE,OAAO;YAC5B,WAAW,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACxC,CAAC;KACF,CAAC;IACF,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;AAC9B,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from "@tanstack/react-query";
2
+ import type { StapelApiError } from "@stapel/core";
3
+ import type { Conversation, ConversationPage } from "../api/types.js";
4
+ import type { ChatThreadWindow } from "./threadWindow.js";
5
+ /** Default window size for a thread — one screenful of history and change. */
6
+ export declare const THREAD_PAGE = 50;
7
+ /** One conversation (participant-only; 403 otherwise). */
8
+ export declare function useConversation(conversationId: string): UseQueryResult<Conversation, StapelApiError>;
9
+ /**
10
+ * The caller's conversations as an infinite (load-more) list, anchored on
11
+ * `updated_at`. `unread_count` is computed server-side per conversation, so
12
+ * the badge needs no second request.
13
+ */
14
+ export declare function useConversations(limit?: number): UseInfiniteQueryResult<InfiniteData<ConversationPage, string | undefined>, StapelApiError>;
15
+ /**
16
+ * The thread — one cache entry holding a contiguous, seq-ordered window
17
+ * (`threadWindow.ts`).
18
+ *
19
+ * ITS REFETCH IS THE POLL. The query function reads the window already in the
20
+ * cache and asks only for what comes after it
21
+ * (`direction=prev&anchor=<tip>`), so `refetch()` means "advance by seq", not
22
+ * "read the newest page again". That is what lets the transport seam stay a
23
+ * signal → refetch bridge: a socket frame and a polling tick take the exact
24
+ * same code path, and the UI cannot tell which arrived.
25
+ *
26
+ * A tail that does not touch the tip (more messages than one page holds, or
27
+ * the paginator's own truncation flag) is a hole. Rather than render a thread
28
+ * with a hole, the window is rebuilt from the newest page — the REST twin of
29
+ * the socket's `error{resync}`, and the reason correctness never depends on
30
+ * delivery.
31
+ */
32
+ export declare function useThread(conversationId: string, limit?: number): UseQueryResult<ChatThreadWindow, StapelApiError>;
33
+ //# sourceMappingURL=queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,YAAY,EACZ,sBAAsB,EACtB,cAAc,EACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAQtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAe1D,8EAA8E;AAC9E,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,0DAA0D;AAC1D,wBAAgB,eAAe,CAC7B,cAAc,EAAE,MAAM,GACrB,cAAc,CAAC,YAAY,EAAE,cAAc,CAAC,CAQ9C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,GAAE,MAA2B,GACjC,sBAAsB,CACvB,YAAY,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC,EAClD,cAAc,CACf,CAgBA;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,SAAS,CACvB,cAAc,EAAE,MAAM,EACtB,KAAK,GAAE,MAAoB,GAC1B,cAAc,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAwBlD"}
@@ -0,0 +1,93 @@
1
+ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { useActiveSessionReady } from "@stapel/core";
3
+ import { useChatApi } from "./context.js";
4
+ import { chatQueryKeys } from "./queryKeys.js";
5
+ import { mergeNewerPage, threadLastSeq, threadWindowFromPage, } from "./threadWindow.js";
6
+ /**
7
+ * Read hooks over the chat API. Staleness follows core's query defaults;
8
+ * freshness is driven by the transport seam (`flows/freshness.ts`), never by
9
+ * a `refetchInterval` sprinkled on a call site.
10
+ *
11
+ * Every hook is gated on {@link useActiveSessionReady}: chat is a member
12
+ * surface with no natural `enabled` condition of its own, and a top-level
13
+ * read that races a still-bootstrapping session reports "loaded, nothing
14
+ * here" for the length of the bootstrap.
15
+ */
16
+ /** Default page size for the conversation list. */
17
+ const CONVERSATIONS_PAGE = 20;
18
+ /** Default window size for a thread — one screenful of history and change. */
19
+ export const THREAD_PAGE = 50;
20
+ /** One conversation (participant-only; 403 otherwise). */
21
+ export function useConversation(conversationId) {
22
+ const api = useChatApi();
23
+ const sessionReady = useActiveSessionReady();
24
+ return useQuery({
25
+ queryKey: chatQueryKeys.conversation(conversationId),
26
+ queryFn: () => api.conversation(conversationId),
27
+ enabled: sessionReady && conversationId.length > 0,
28
+ });
29
+ }
30
+ /**
31
+ * The caller's conversations as an infinite (load-more) list, anchored on
32
+ * `updated_at`. `unread_count` is computed server-side per conversation, so
33
+ * the badge needs no second request.
34
+ */
35
+ export function useConversations(limit = CONVERSATIONS_PAGE) {
36
+ const api = useChatApi();
37
+ const sessionReady = useActiveSessionReady();
38
+ return useInfiniteQuery({
39
+ queryKey: chatQueryKeys.conversations(),
40
+ queryFn: ({ pageParam }) => api.conversations({
41
+ direction: "next",
42
+ limit,
43
+ ...(pageParam !== undefined ? { anchor: pageParam } : {}),
44
+ }),
45
+ initialPageParam: undefined,
46
+ getNextPageParam: (last) => last.has_next ? (last.next_anchor ?? undefined) : undefined,
47
+ enabled: sessionReady,
48
+ });
49
+ }
50
+ /**
51
+ * The thread — one cache entry holding a contiguous, seq-ordered window
52
+ * (`threadWindow.ts`).
53
+ *
54
+ * ITS REFETCH IS THE POLL. The query function reads the window already in the
55
+ * cache and asks only for what comes after it
56
+ * (`direction=prev&anchor=<tip>`), so `refetch()` means "advance by seq", not
57
+ * "read the newest page again". That is what lets the transport seam stay a
58
+ * signal → refetch bridge: a socket frame and a polling tick take the exact
59
+ * same code path, and the UI cannot tell which arrived.
60
+ *
61
+ * A tail that does not touch the tip (more messages than one page holds, or
62
+ * the paginator's own truncation flag) is a hole. Rather than render a thread
63
+ * with a hole, the window is rebuilt from the newest page — the REST twin of
64
+ * the socket's `error{resync}`, and the reason correctness never depends on
65
+ * delivery.
66
+ */
67
+ export function useThread(conversationId, limit = THREAD_PAGE) {
68
+ const api = useChatApi();
69
+ const queryClient = useQueryClient();
70
+ const sessionReady = useActiveSessionReady();
71
+ const queryKey = chatQueryKeys.thread(conversationId);
72
+ return useQuery({
73
+ queryKey,
74
+ queryFn: async () => {
75
+ const previous = queryClient.getQueryData(queryKey);
76
+ const tip = previous ? threadLastSeq(previous) : 0;
77
+ if (!previous || tip === 0) {
78
+ return threadWindowFromPage(await api.messages(conversationId, { limit }));
79
+ }
80
+ const tail = await api.messages(conversationId, {
81
+ direction: "prev",
82
+ anchor: String(tip),
83
+ limit,
84
+ });
85
+ const merged = mergeNewerPage(previous, tail);
86
+ if (!merged.gap)
87
+ return merged.window;
88
+ return threadWindowFromPage(await api.messages(conversationId, { limit }));
89
+ },
90
+ enabled: sessionReady && conversationId.length > 0,
91
+ });
92
+ }
93
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAMnF,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EACL,cAAc,EACd,aAAa,EACb,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAG3B;;;;;;;;;GASG;AAEH,mDAAmD;AACnD,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAC9B,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AAE9B,0DAA0D;AAC1D,MAAM,UAAU,eAAe,CAC7B,cAAsB;IAEtB,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,OAAO,QAAQ,CAAC;QACd,QAAQ,EAAE,aAAa,CAAC,YAAY,CAAC,cAAc,CAAC;QACpD,OAAO,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC,cAAc,CAAC;QAC/C,OAAO,EAAE,YAAY,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;KACnD,CAAC,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAgB,kBAAkB;IAKlC,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,OAAO,gBAAgB,CAAC;QACtB,QAAQ,EAAE,aAAa,CAAC,aAAa,EAAE;QACvC,OAAO,EAAE,CAAC,EAAE,SAAS,EAAE,EAAE,EAAE,CACzB,GAAG,CAAC,aAAa,CAAC;YAChB,SAAS,EAAE,MAAM;YACjB,KAAK;YACL,GAAG,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC1D,CAAC;QACJ,gBAAgB,EAAE,SAA+B;QACjD,gBAAgB,EAAE,CAAC,IAAI,EAAE,EAAE,CACzB,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7D,OAAO,EAAE,YAAY;KACtB,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CACvB,cAAsB,EACtB,QAAgB,WAAW;IAE3B,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACtD,OAAO,QAAQ,CAAC;QACd,QAAQ;QACR,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,MAAM,QAAQ,GAAG,WAAW,CAAC,YAAY,CAAmB,QAAQ,CAAC,CAAC;YACtE,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACnD,IAAI,CAAC,QAAQ,IAAI,GAAG,KAAK,CAAC,EAAE,CAAC;gBAC3B,OAAO,oBAAoB,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;YAC7E,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE;gBAC9C,SAAS,EAAE,MAAM;gBACjB,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC;gBACnB,KAAK;aACN,CAAC,CAAC;YACH,MAAM,MAAM,GAAG,cAAc,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;YAC9C,IAAI,CAAC,MAAM,CAAC,GAAG;gBAAE,OAAO,MAAM,CAAC,MAAM,CAAC;YACtC,OAAO,oBAAoB,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,cAAc,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;QAC7E,CAAC;QACD,OAAO,EAAE,YAAY,IAAI,cAAc,CAAC,MAAM,GAAG,CAAC;KACnD,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,8 @@
1
+ export declare const chatQueryKeys: {
2
+ readonly all: readonly ["chat"];
3
+ conversations(): readonly ["chat", "conversations"];
4
+ conversation(conversationId: string): readonly ["chat", "conversation", string];
5
+ thread(conversationId: string): readonly ["chat", "thread", string];
6
+ readMarker(conversationId: string): readonly ["chat", "read-marker", string];
7
+ };
8
+ //# sourceMappingURL=queryKeys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.d.ts","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,aAAa,EAAE;IAC1B,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAChC,aAAa,IAAI,SAAS,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACpD,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC;IAChF,MAAM,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IACpE,UAAU,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,CAAC;CAiB9E,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
3
+ * Everything under the `"chat"` root so a host can invalidate the whole module
4
+ * or match a single thread. Persist scope is per-user via core's query runtime
5
+ * (`setPersistUser`). Explicit tuple return types satisfy
6
+ * `--isolatedDeclarations`.
7
+ */
8
+ const ROOT = "chat";
9
+ export const chatQueryKeys = {
10
+ all: [ROOT],
11
+ // The infinite list shares one root key across pages (its pages live under
12
+ // a single cache entry).
13
+ conversations: () => [ROOT, "conversations"],
14
+ conversation: (conversationId) => [ROOT, "conversation", conversationId],
15
+ // The thread is ONE cache entry holding a merged, seq-ordered window — not
16
+ // a page list. Its query function reads this entry to decide what to ask
17
+ // for next (a tail by seq, not a blind re-read), which is what makes a
18
+ // plain `refetch()` mean "poll by seq" and lets the transport seam stay a
19
+ // signal → refetch bridge.
20
+ thread: (conversationId) => [ROOT, "thread", conversationId],
21
+ // Not a server read: the highest `upto_seq` this client has already
22
+ // reported. Lives in the query cache so it survives a remount and is wiped
23
+ // at logout with everything else (frontend-core-architecture-v2 §43.3).
24
+ readMarker: (conversationId) => [ROOT, "read-marker", conversationId],
25
+ };
26
+ //# sourceMappingURL=queryKeys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.js","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,IAAI,GAAG,MAAe,CAAC;AAE7B,MAAM,CAAC,MAAM,aAAa,GAMtB;IACF,GAAG,EAAE,CAAC,IAAI,CAAC;IACX,2EAA2E;IAC3E,yBAAyB;IACzB,aAAa,EAAE,GAAG,EAAE,CAAC,CAAC,IAAI,EAAE,eAAe,CAAC;IAC5C,YAAY,EAAE,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,cAAc,EAAE,cAAc,CAAC;IACxE,2EAA2E;IAC3E,yEAAyE;IACzE,uEAAuE;IACvE,0EAA0E;IAC1E,2BAA2B;IAC3B,MAAM,EAAE,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,cAAc,CAAC;IAC5D,oEAAoE;IACpE,2EAA2E;IAC3E,wEAAwE;IACxE,UAAU,EAAE,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,aAAa,EAAE,cAAc,CAAC;CACtE,CAAC"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The read marker, and the one rule that governs it: it only ever moves
3
+ * forward.
4
+ *
5
+ * The server already enforces that (`services.mark_read` never lowers
6
+ * `last_read_seq`), so a client that sends a smaller value is not corrupting
7
+ * anything — it is spending a request to be ignored, and, worse, telling the
8
+ * UI a story that the next list refresh will contradict. The guard is here so
9
+ * the client's own belief matches the server's.
10
+ *
11
+ * WHY THE CLIENT HAS TO REMEMBER AT ALL. `ConversationResponse.participants[]`
12
+ * carries `last_read_seq` — for every participant, with no marker for which
13
+ * one is the caller (the response has no "me"). `unread_count` is the only
14
+ * caller-relative number the endpoint returns. So the highest marker THIS
15
+ * client has reported cannot be read back off the wire; it is remembered in
16
+ * the query cache (`chatQueryKeys.readMarker`), which the session layer wipes
17
+ * at logout along with everything else.
18
+ */
19
+ /**
20
+ * The value to send, or `null` for "already reported — send nothing".
21
+ *
22
+ * `candidate` of 0 (an empty thread) is never sent: there is no such seq.
23
+ */
24
+ export declare function nextReadMarker(known: number | undefined, candidate: number): number | null;
25
+ //# sourceMappingURL=readMarker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readMarker.d.ts","sourceRoot":"","sources":["../../src/model/readMarker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAIf"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The read marker, and the one rule that governs it: it only ever moves
3
+ * forward.
4
+ *
5
+ * The server already enforces that (`services.mark_read` never lowers
6
+ * `last_read_seq`), so a client that sends a smaller value is not corrupting
7
+ * anything — it is spending a request to be ignored, and, worse, telling the
8
+ * UI a story that the next list refresh will contradict. The guard is here so
9
+ * the client's own belief matches the server's.
10
+ *
11
+ * WHY THE CLIENT HAS TO REMEMBER AT ALL. `ConversationResponse.participants[]`
12
+ * carries `last_read_seq` — for every participant, with no marker for which
13
+ * one is the caller (the response has no "me"). `unread_count` is the only
14
+ * caller-relative number the endpoint returns. So the highest marker THIS
15
+ * client has reported cannot be read back off the wire; it is remembered in
16
+ * the query cache (`chatQueryKeys.readMarker`), which the session layer wipes
17
+ * at logout along with everything else.
18
+ */
19
+ /**
20
+ * The value to send, or `null` for "already reported — send nothing".
21
+ *
22
+ * `candidate` of 0 (an empty thread) is never sent: there is no such seq.
23
+ */
24
+ export function nextReadMarker(known, candidate) {
25
+ if (!Number.isFinite(candidate) || candidate <= 0)
26
+ return null;
27
+ if (known !== undefined && candidate <= known)
28
+ return null;
29
+ return candidate;
30
+ }
31
+ //# sourceMappingURL=readMarker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readMarker.js","sourceRoot":"","sources":["../../src/model/readMarker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,KAAyB,EACzB,SAAiB;IAEjB,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/D,IAAI,KAAK,KAAK,SAAS,IAAI,SAAS,IAAI,KAAK;QAAE,OAAO,IAAI,CAAC;IAC3D,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -0,0 +1,50 @@
1
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
2
+ import type { ChatApi } from "../api/chatApi.js";
3
+ import type { ChatReconnectOptions, ChatWebSocketFactory } from "../realtime/chatSocket.js";
4
+ /**
5
+ * Host-side realtime configuration. Every field is optional and every default
6
+ * degrades to polling rather than to a broken socket — a host that says
7
+ * nothing gets the right behaviour on a WSGI deployment (no sockets at all)
8
+ * and on an ASGI one (sockets, if the origin can be resolved).
9
+ */
10
+ export interface ChatRealtimeOptions {
11
+ /**
12
+ * Where this deployment mounts `stapel_chat.routing.websocket_urlpatterns`,
13
+ * e.g. `wss://darom.example/ws/chat/`. Omit to derive it from `baseUrl`
14
+ * (the canonical `/ws/chat/` at the API's own origin). Pass `null` to turn
15
+ * the socket transport OFF explicitly — a host that knows its backend runs
16
+ * under WSGI says so here instead of letting every tab fail a handshake
17
+ * six times first.
18
+ */
19
+ readonly socketUrl?: string | null;
20
+ /** Injectable socket transport (tests, instrumentation, React Native). */
21
+ readonly webSocket?: ChatWebSocketFactory;
22
+ /** Reconnect backoff knobs. */
23
+ readonly reconnect?: ChatReconnectOptions;
24
+ /**
25
+ * Origin used to resolve a relative `baseUrl`. Defaults to the browser's
26
+ * own; irrelevant when `baseUrl` is absolute.
27
+ */
28
+ readonly origin?: string | null;
29
+ }
30
+ /** The realtime configuration as the transport seam reads it. */
31
+ export interface ChatRealtimeConfig {
32
+ /** `null` — this build will not open sockets; the seam polls. */
33
+ readonly socketBase: string | null;
34
+ readonly webSocket: ChatWebSocketFactory | undefined;
35
+ readonly reconnect: ChatReconnectOptions | undefined;
36
+ }
37
+ /**
38
+ * The wired chat runtime — core's `ModuleRuntime` bound to this pair's API,
39
+ * PLUS the one thing chat has and a plain REST pair does not: where its
40
+ * socket lives. The socket URL is runtime configuration, not module state, so
41
+ * it rides here rather than through a second provider.
42
+ */
43
+ export type ChatRuntime = ModuleRuntime<ChatApi> & {
44
+ readonly realtime: ChatRealtimeConfig;
45
+ };
46
+ export interface CreateChatRuntimeOptions extends CreateModuleRuntimeOptions {
47
+ readonly realtime?: ChatRealtimeOptions;
48
+ }
49
+ export declare function createChatRuntime(options: CreateChatRuntimeOptions): ChatRuntime;
50
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/model/runtime.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE9E,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEjD,OAAO,KAAK,EACV,oBAAoB,EACpB,oBAAoB,EACrB,MAAM,2BAA2B,CAAC;AAGnC;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,oBAAoB,CAAC;IAC1C,+BAA+B;IAC/B,QAAQ,CAAC,SAAS,CAAC,EAAE,oBAAoB,CAAC;IAC1C;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,iEAAiE;AACjE,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,SAAS,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACrD,QAAQ,CAAC,SAAS,EAAE,oBAAoB,GAAG,SAAS,CAAC;CACtD;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,aAAa,CAAC,OAAO,CAAC,GAAG;IACjD,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;CACvC,CAAC;AAEF,MAAM,WAAW,wBAAyB,SAAQ,0BAA0B;IAC1E,QAAQ,CAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAC;CACzC;AA0BD,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,WAAW,CAGb"}
@@ -0,0 +1,27 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import { createChatApi } from "../api/chatApi.js";
3
+ import { canOpenWebSocket } from "../realtime/chatSocket.js";
4
+ import { deriveChatSocketBase } from "../realtime/streams.js";
5
+ function currentOrigin() {
6
+ return typeof location !== "undefined" ? location.origin : null;
7
+ }
8
+ function resolveRealtime(baseUrl, options) {
9
+ const explicit = options?.socketUrl;
10
+ const socketBase = explicit === null
11
+ ? null
12
+ : explicit !== undefined
13
+ ? explicit
14
+ : canOpenWebSocket()
15
+ ? deriveChatSocketBase(baseUrl, options?.origin ?? currentOrigin())
16
+ : null;
17
+ return {
18
+ socketBase,
19
+ webSocket: options?.webSocket,
20
+ reconnect: options?.reconnect,
21
+ };
22
+ }
23
+ export function createChatRuntime(options) {
24
+ const runtime = createModuleRuntime(createChatApi, options);
25
+ return { ...runtime, realtime: resolveRealtime(options.baseUrl, options.realtime) };
26
+ }
27
+ //# sourceMappingURL=runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.js","sourceRoot":"","sources":["../../src/model/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAElD,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAK7D,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAmD9D,SAAS,aAAa;IACpB,OAAO,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAClE,CAAC;AAED,SAAS,eAAe,CACtB,OAAe,EACf,OAAwC;IAExC,MAAM,QAAQ,GAAG,OAAO,EAAE,SAAS,CAAC;IACpC,MAAM,UAAU,GACd,QAAQ,KAAK,IAAI;QACf,CAAC,CAAC,IAAI;QACN,CAAC,CAAC,QAAQ,KAAK,SAAS;YACtB,CAAC,CAAC,QAAQ;YACV,CAAC,CAAC,gBAAgB,EAAE;gBAClB,CAAC,CAAC,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,aAAa,EAAE,CAAC;gBACnE,CAAC,CAAC,IAAI,CAAC;IACf,OAAO;QACL,UAAU;QACV,SAAS,EAAE,OAAO,EAAE,SAAS;QAC7B,SAAS,EAAE,OAAO,EAAE,SAAS;KAC9B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,OAAiC;IAEjC,MAAM,OAAO,GAAG,mBAAmB,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;IAC5D,OAAO,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,eAAe,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;AACtF,CAAC"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The thread store: a merged, `seq`-ordered window over a conversation, and
3
+ * the four pure functions that move it.
4
+ *
5
+ * WHY A WINDOW AND NOT A PAGE LIST. A chat thread grows at BOTH ends: older
6
+ * history is paged backwards on scroll, new messages arrive at the tip while
7
+ * the reader is looking at it. TanStack's bidirectional infinite query cannot
8
+ * express the second half — `getPreviousPageParam` is answered from the page
9
+ * the server sent, and at the tip the server correctly says "no previous
10
+ * page", which would freeze the tail forever. So the thread is ONE cache
11
+ * entry holding a contiguous run of messages, and the two directions are two
12
+ * merges into it.
13
+ *
14
+ * THE ORDERING RULE IS THE BACKEND'S, NOT OURS. `seq` is a gapless, total
15
+ * per-conversation order (`stapel-chat/MODULE.md`: "seq is gapless and total
16
+ * — the canonical anchor for history and the resume cursor for realtime"), and
17
+ * ordering by timestamp is an explicit anti-pattern there: two messages in the
18
+ * same millisecond still have a definite order. Everything below therefore
19
+ * sorts, dedupes and detects gaps by `seq` alone.
20
+ *
21
+ * ONE MORE THING THE PAGINATOR MAKES US DO. Item order depends on the
22
+ * DIRECTION asked for: core's `AnchorPagination` returns newest-first for
23
+ * `next` and reverses to oldest-first for `prev` (`pagination.py`,
24
+ * `items[::-1]`). Rather than remember which call produced which order, every
25
+ * merge sorts by `seq` — the total order is cheaper to apply than to
26
+ * remember.
27
+ */
28
+ import type { ChatMessage, MessagePage } from "../api/types.js";
29
+ /**
30
+ * A contiguous run of messages, ascending by `seq`.
31
+ *
32
+ * "Contiguous" is load-bearing: because `seq` is gapless, a window whose seqs
33
+ * are consecutive integers provably has no hole in it. Every merge below
34
+ * either preserves that or refuses the merge and reports a gap — it never
35
+ * quietly stitches two runs with a hole between them, which is the defect
36
+ * that makes a chat show yesterday's message under today's.
37
+ */
38
+ export interface ChatThreadWindow {
39
+ /** Ascending by `seq`, no duplicates, no holes. */
40
+ readonly messages: readonly ChatMessage[];
41
+ /** Older history exists before `messages[0]`. */
42
+ readonly hasOlder: boolean;
43
+ /** Anchor for the next backfill page (`direction=next`), if any. */
44
+ readonly olderAnchor: string | null;
45
+ }
46
+ /** A merge that either advanced the window, or found a hole and refused. */
47
+ export interface ThreadMergeResult {
48
+ readonly window: ChatThreadWindow;
49
+ /**
50
+ * `true` when the incoming messages do not touch the window's tip: more
51
+ * arrived than one page holds. The caller re-hydrates (the REST twin of the
52
+ * socket's `error{resync}`) rather than rendering a thread with a hole.
53
+ */
54
+ readonly gap: boolean;
55
+ }
56
+ export declare const EMPTY_THREAD_WINDOW: ChatThreadWindow;
57
+ /** The tip: the highest `seq` in the window, or 0 for an empty one. */
58
+ export declare function threadLastSeq(window: ChatThreadWindow): number;
59
+ /** The oldest `seq` in the window, or 0 for an empty one. */
60
+ export declare function threadFirstSeq(window: ChatThreadWindow): number;
61
+ /**
62
+ * A fresh window from the newest page (`GET …/messages` with no anchor).
63
+ * This is also the resync path: whatever was loaded before is dropped,
64
+ * because a window that cannot be proven contiguous is worse than a short
65
+ * one.
66
+ */
67
+ export declare function threadWindowFromPage(page: MessagePage): ChatThreadWindow;
68
+ /**
69
+ * Merge a backfill page (`direction=next` from the window's oldest seq) onto
70
+ * the FRONT. Anything at or above the current oldest is dropped as a
71
+ * duplicate — the anchor is exclusive, so this only fires on a retry.
72
+ */
73
+ export declare function mergeOlderPage(window: ChatThreadWindow, page: MessagePage): ChatThreadWindow;
74
+ /**
75
+ * Merge a tail page (`direction=prev` anchored on the window's tip) onto the
76
+ * END — the poll-by-seq step.
77
+ *
78
+ * Two independent hole detectors, because either one alone can be fooled:
79
+ * - the seq test (`first new seq === tip + 1`), which is only meaningful
80
+ * because seq is gapless;
81
+ * - the paginator's own `has_prev`, which is how a window that was TRUNCATED
82
+ * (more messages above the anchor than `limit`) says so.
83
+ */
84
+ export declare function mergeNewerPage(window: ChatThreadWindow, page: MessagePage): ThreadMergeResult;
85
+ /**
86
+ * Merge ONE message onto the end — a live socket frame, or the row a `send`
87
+ * just returned.
88
+ *
89
+ * Already-known seq is dropped (the socket dedupes too, but the sender's own
90
+ * REST answer and the frame it fans out to itself are two paths to the same
91
+ * row, and only this rule makes them idempotent). A seq beyond the tip + 1 is
92
+ * a hole: the window is left untouched and the caller re-hydrates.
93
+ */
94
+ export declare function mergeMessage(window: ChatThreadWindow, message: ChatMessage): ThreadMergeResult;
95
+ //# sourceMappingURL=threadWindow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"threadWindow.d.ts","sourceRoot":"","sources":["../../src/model/threadWindow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEhE;;;;;;;;GAQG;AACH,MAAM,WAAW,gBAAgB;IAC/B,mDAAmD;IACnD,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAC;IAC1C,iDAAiD;IACjD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;CACvB;AAED,eAAO,MAAM,mBAAmB,EAAE,gBAIjC,CAAC;AAMF,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAG9D;AAED,6DAA6D;AAC7D,wBAAgB,cAAc,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAG/D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,gBAAgB,CAMxE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,WAAW,GAChB,gBAAgB,CAalB;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,WAAW,GAChB,iBAAiB,CAoBnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,WAAW,GACnB,iBAAiB,CAQnB"}