pi-roundtable-web 0.28.0 → 0.29.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.
Files changed (236) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +4 -4
  3. package/dist/chunk-account-66e6ezdq.js +1 -0
  4. package/dist/{chunk-action-menu-qpp9bdxh.js → chunk-action-menu-ap5xn7bf.js} +1 -1
  5. package/dist/chunk-agent-sapt5qhn.js +1 -0
  6. package/dist/chunk-arranged-rooms-n6bk696f.js +1 -0
  7. package/dist/chunk-attachments-p5v9gqyt.js +102 -0
  8. package/dist/chunk-bulk-actions-aze4ewc4.js +1 -0
  9. package/dist/chunk-character-creator-m7tw20ef.js +225 -0
  10. package/dist/{chunk-codeeditor-jgj32247.js → chunk-codeeditor-yy6pq8mv.js} +2 -2
  11. package/dist/chunk-confirm-0cajsfhk.js +1 -0
  12. package/dist/chunk-connectors-tw0tbm21.js +37 -0
  13. package/dist/chunk-conversations-wkm23m5r.js +1 -0
  14. package/dist/chunk-council-sheet-mz7s23nq.js +2 -0
  15. package/dist/chunk-council-view-zgvzxtgn.js +1 -0
  16. package/dist/{chunk-drag-04qfef58.js → chunk-drag-d1bpy8w6.js} +1 -1
  17. package/dist/{chunk-entry-mws704mc.js → chunk-entry-7zr5b1fp.js} +1 -1
  18. package/dist/chunk-folder-list-bs4eh3g9.js +1 -0
  19. package/dist/chunk-folds-eez1q41c.js +1 -0
  20. package/dist/{chunk-gate-vxetvn8t.js → chunk-gate-dt91egcd.js} +1 -1
  21. package/dist/chunk-host-icons-nfe2t8tk.js +1 -0
  22. package/dist/chunk-host-picture-4rtswxwa.js +1 -0
  23. package/dist/chunk-host-state-1vxzmmq6.js +1 -0
  24. package/dist/chunk-host-ui-x38bf0et.js +1 -0
  25. package/dist/{chunk-index-zj0gjcvr.js → chunk-index-0h99xcs3.js} +2 -2
  26. package/dist/{chunk-index-e2gy763y.js → chunk-index-1kb8tv4v.js} +2 -2
  27. package/dist/chunk-index-24cggw6k.js +50 -0
  28. package/dist/chunk-index-2g8xtwqg.js +2 -0
  29. package/dist/chunk-index-3n38xe7m.js +2 -0
  30. package/dist/{chunk-index-n0cmnwd2.js → chunk-index-3tksdmx7.js} +2 -2
  31. package/dist/{chunk-index-2822z600.js → chunk-index-4a1tdadd.js} +3 -3
  32. package/dist/{chunk-index-2syyyjnq.js → chunk-index-53fwc7sc.js} +2 -2
  33. package/dist/chunk-index-55ps4x5n.js +2 -0
  34. package/dist/{chunk-index-q5fyrtx4.js → chunk-index-5j1v2q7w.js} +1 -1
  35. package/dist/chunk-index-5w6tb7p0.js +2 -0
  36. package/dist/chunk-index-6btdp13p.js +2 -0
  37. package/dist/chunk-index-6m7dwsmv.js +1 -0
  38. package/dist/{chunk-index-20xgx0k2.js → chunk-index-6rdywten.js} +2 -2
  39. package/dist/{chunk-index-3gfbhmpb.js → chunk-index-6s2pqwna.js} +2 -2
  40. package/dist/{chunk-index-fepsbhr6.js → chunk-index-7mrkq793.js} +2 -2
  41. package/dist/chunk-index-7xcbpeqz.js +2 -0
  42. package/dist/chunk-index-88wh5eyj.js +2 -0
  43. package/dist/{chunk-index-6cg8xp1c.js → chunk-index-8pbwr3rs.js} +1 -1
  44. package/dist/{chunk-index-jc3r4qam.js → chunk-index-8wpvj7k5.js} +2 -2
  45. package/dist/chunk-index-97dcp1vh.js +1 -0
  46. package/dist/chunk-index-a7e6rhxy.js +2 -0
  47. package/dist/{chunk-index-2tr41qht.js → chunk-index-bxf8eq1s.js} +2 -2
  48. package/dist/chunk-index-c8gm1crh.js +120 -0
  49. package/dist/{chunk-index-2y8r4s4f.js → chunk-index-ce7hsda9.js} +2 -2
  50. package/dist/chunk-index-cfesmk1f.js +2 -0
  51. package/dist/{chunk-index-m81362sc.js → chunk-index-cmzvq3nf.js} +1 -1
  52. package/dist/{chunk-index-7qd604rq.js → chunk-index-d4mx2vvf.js} +1 -1
  53. package/dist/chunk-index-d74tpnkd.js +2 -0
  54. package/dist/chunk-index-d941ae7c.js +2 -0
  55. package/dist/{chunk-index-e35v554x.js → chunk-index-dr0jr7d3.js} +3 -3
  56. package/dist/chunk-index-en728705.js +2 -0
  57. package/dist/chunk-index-fb3kzyyp.js +2 -0
  58. package/dist/{chunk-index-3yy10a5h.js → chunk-index-ffnd0jcj.js} +2 -2
  59. package/dist/chunk-index-fn6305vn.js +3 -0
  60. package/dist/{chunk-index-zcfr8jdv.js → chunk-index-gdh7wsd5.js} +12 -12
  61. package/dist/{chunk-index-2hvn2kte.js → chunk-index-m76gdwnk.js} +2 -2
  62. package/dist/chunk-index-mr391t49.js +2 -0
  63. package/dist/chunk-index-n3khm9fq.js +2 -0
  64. package/dist/{chunk-index-sm7kfb07.js → chunk-index-nn4w6a04.js} +2 -2
  65. package/dist/{chunk-index-gwkhs8s3.js → chunk-index-nr275sa7.js} +2 -2
  66. package/dist/{chunk-index-x60n1me6.js → chunk-index-pgxgq61x.js} +2 -2
  67. package/dist/chunk-index-prq011b6.js +2 -0
  68. package/dist/{chunk-index-v9gbt5tz.js → chunk-index-q4k9vk04.js} +19 -3
  69. package/dist/chunk-index-rgdaec7y.js +2 -0
  70. package/dist/chunk-index-rjs1can6.js +2 -0
  71. package/dist/chunk-index-s4a8e4qm.js +2 -0
  72. package/dist/{chunk-index-s9p27f9b.js → chunk-index-sz381vda.js} +3 -3
  73. package/dist/{chunk-index-5hbq6g2e.css → chunk-index-t7csmbpy.css} +1 -1
  74. package/dist/{chunk-index-rgf4rvns.js → chunk-index-v9jqyr1f.js} +3 -3
  75. package/dist/{chunk-index-rsd8rftk.js → chunk-index-vpq2cj14.js} +2 -2
  76. package/dist/chunk-index-vsaggfns.js +2 -0
  77. package/dist/{chunk-index-p7jkr7gv.js → chunk-index-w17wgjby.js} +5 -5
  78. package/dist/{chunk-index-c7dbvkrq.js → chunk-index-wgrx2ybn.js} +2 -2
  79. package/dist/chunk-index-wsv46y9p.js +2 -0
  80. package/dist/{chunk-index-4t6vnwap.js → chunk-index-xbj816p0.js} +2 -2
  81. package/dist/{chunk-index-s53bxwkk.js → chunk-index-yr758n6t.js} +2 -2
  82. package/dist/{chunk-index-5t2jkecy.js → chunk-index-ysv36nbk.js} +2 -2
  83. package/dist/{chunk-index-zt19fdmc.js → chunk-index-ze9p4npm.js} +2 -2
  84. package/dist/chunk-index-zfdvyrjt.js +2 -0
  85. package/dist/chunk-lang-css-3h22se37.js +1 -0
  86. package/dist/{chunk-lang-dockerfile-n4bt8jbt.js → chunk-lang-dockerfile-y24eejnh.js} +1 -1
  87. package/dist/{chunk-lang-go-9k4y107e.js → chunk-lang-go-qzgv1161.js} +3 -3
  88. package/dist/chunk-lang-html-9rvrmmpg.js +1 -0
  89. package/dist/chunk-lang-javascript-4kv8s3tk.js +1 -0
  90. package/dist/{chunk-lang-json-hr12bzs1.js → chunk-lang-json-99v80v88.js} +1 -1
  91. package/dist/{chunk-lang-markdown-ttzcpgkg.js → chunk-lang-markdown-gn8he877.js} +4 -4
  92. package/dist/{chunk-lang-python-ntj8maem.js → chunk-lang-python-prsw4p4k.js} +2 -2
  93. package/dist/{chunk-lang-rust-gv8y859d.js → chunk-lang-rust-g6xqta16.js} +1 -1
  94. package/dist/{chunk-lang-shell-5zzs09b7.js → chunk-lang-shell-dmpnzc6w.js} +1 -1
  95. package/dist/{chunk-lang-sql-6vmze4pg.js → chunk-lang-sql-wz7ca27b.js} +2 -2
  96. package/dist/{chunk-lang-toml-tt0d4rw9.js → chunk-lang-toml-160prtq4.js} +1 -1
  97. package/dist/chunk-lang-typescript-mp0yw903.js +1 -0
  98. package/dist/{chunk-lang-xml-r2sbqnjv.js → chunk-lang-xml-9htkz2dd.js} +1 -1
  99. package/dist/{chunk-lang-yaml-ex31qbph.js → chunk-lang-yaml-08749wd5.js} +1 -1
  100. package/dist/chunk-layout-actions-tdx896xf.js +1 -0
  101. package/dist/chunk-menu-items-trhybv1g.js +1 -0
  102. package/dist/chunk-mode-toggle-4dtfphq4.js +1 -0
  103. package/dist/{chunk-note-row-40ypghn9.js → chunk-note-row-28e5x7n8.js} +33 -4
  104. package/dist/chunk-notes-5nkayhw8.js +1 -0
  105. package/dist/chunk-notice-bell-xz8a04am.js +1 -0
  106. package/dist/{chunk-notices-tefx93s6.js → chunk-notices-d0cwvk27.js} +1 -1
  107. package/dist/chunk-onboarding-zs4rtea9.js +1 -0
  108. package/dist/{chunk-overview-zrx3bpwf.js → chunk-overview-9sb3ywa8.js} +1 -1
  109. package/dist/chunk-palette-5sfhrv4x.js +1 -0
  110. package/dist/chunk-placement-items-e2neja55.js +1 -0
  111. package/dist/{chunk-profile-tyb48b79.js → chunk-profile-jffhtpqf.js} +1 -1
  112. package/dist/chunk-prompt-history-1jz2gk3v.js +1 -0
  113. package/dist/chunk-prompts-0t30ttd0.js +1 -0
  114. package/dist/chunk-room-confirms-6595921h.js +1 -0
  115. package/dist/{chunk-room-menu-ha1evv71.js → chunk-room-menu-hwkjyxwe.js} +1 -1
  116. package/dist/chunk-rooms-menu-button-k0eq1tcr.js +1 -0
  117. package/dist/{chunk-schedules-a5pewkhd.js → chunk-schedules-e12y8fw2.js} +1 -1
  118. package/dist/{chunk-seat-menu-eh3ymmks.js → chunk-seat-menu-n2bcw0mf.js} +1 -1
  119. package/dist/chunk-sheet-hosts-myvm2vhd.js +29 -0
  120. package/dist/chunk-shortcut-help-434sqnxc.js +26 -0
  121. package/dist/chunk-shortcuts-mjxph0g7.js +1 -0
  122. package/dist/chunk-skill-editor-98492yrt.js +8 -0
  123. package/dist/chunk-skills-mfv8ppfh.js +1 -0
  124. package/dist/chunk-start-over-line-m526193z.js +16 -0
  125. package/dist/{chunk-switcher-list-dtfh87zy.js → chunk-switcher-list-5ngjar24.js} +1 -1
  126. package/dist/{chunk-tab-dot-qd640mjr.js → chunk-tab-dot-tzjkahj5.js} +1 -1
  127. package/dist/chunk-table-xbtbex2z.js +1 -0
  128. package/dist/{chunk-table2d-5nrhfwft.js → chunk-table2d-61bvrykm.js} +1 -1
  129. package/dist/{chunk-table3d-5cb66d99.js → chunk-table3d-5a7vbgyj.js} +172 -172
  130. package/dist/chunk-thread-actions-bf6vf2mt.js +1 -0
  131. package/dist/{chunk-thread-cards-kgt22sbp.js → chunk-thread-cards-n0n603vf.js} +1 -1
  132. package/dist/chunk-thread-list-7bkdn51x.js +227 -0
  133. package/dist/{chunk-transcript-a0p3vwh6.js → chunk-transcript-8ztrkt6g.js} +1 -1
  134. package/dist/chunk-waiting-chip-y83c90v3.js +32 -0
  135. package/dist/chunk-waiting-s273kcgz.js +1 -0
  136. package/dist/chunk-where-jr791gzj.js +1 -0
  137. package/dist/chunk-workspace-dw71mshw.js +586 -0
  138. package/dist/chunk-zh-tw-vm5d0w6j.js +1 -0
  139. package/dist/index.html +1 -1
  140. package/dist/sw.js +1 -1
  141. package/package.json +3 -3
  142. package/src/api-types.ts +51 -0
  143. package/src/app/app.ts +7 -0
  144. package/src/app/card-log.ts +10 -2
  145. package/src/app/protocol.ts +18 -0
  146. package/src/app/queued-delivery.ts +107 -0
  147. package/src/app/queued-frames.ts +115 -0
  148. package/src/app/room-log.ts +39 -7
  149. package/src/app/rooms.ts +30 -2
  150. package/src/app/routes.ts +123 -7
  151. package/src/app/skill-group-routes.ts +126 -0
  152. package/src/app/socket.ts +12 -2
  153. package/src/app/surface.ts +37 -6
  154. package/src/app/zip.ts +124 -0
  155. package/src/auth/sessions.ts +9 -1
  156. package/src/authz.ts +1 -0
  157. package/src/host/cards.ts +10 -3
  158. package/src/host/stored-json.ts +23 -0
  159. package/src/i18n/zh-tw.ts +45 -2
  160. package/src/notices/mount.ts +1 -0
  161. package/src/notices/store.ts +9 -1
  162. package/src/push/channel.ts +1 -0
  163. package/src/web-plugin.ts +5 -1
  164. package/dist/chunk-account-678dgbww.js +0 -1
  165. package/dist/chunk-agent-x0yvvdq2.js +0 -1
  166. package/dist/chunk-arranged-rooms-6rdkk8t1.js +0 -1
  167. package/dist/chunk-bulk-actions-zbwdaxtc.js +0 -1
  168. package/dist/chunk-character-creator-215psy17.js +0 -1
  169. package/dist/chunk-confirm-2pzrt272.js +0 -1
  170. package/dist/chunk-connectors-zha2d834.js +0 -37
  171. package/dist/chunk-conversations-fb071rez.js +0 -1
  172. package/dist/chunk-council-sheet-k5fycefd.js +0 -2
  173. package/dist/chunk-folder-list-qxhmczwv.js +0 -1
  174. package/dist/chunk-folds-b7asegfs.js +0 -1
  175. package/dist/chunk-group-scene-63yhh1mh.js +0 -1
  176. package/dist/chunk-group-table-zy4hmgk3.js +0 -1
  177. package/dist/chunk-host-icons-tyktazzz.js +0 -1
  178. package/dist/chunk-host-picture-ebhejn2k.js +0 -1
  179. package/dist/chunk-host-state-wajep0yr.js +0 -1
  180. package/dist/chunk-host-ui-pc9r24e3.js +0 -1
  181. package/dist/chunk-index-1af8vsyb.js +0 -2
  182. package/dist/chunk-index-254701sm.js +0 -3
  183. package/dist/chunk-index-31m1b2f5.js +0 -2
  184. package/dist/chunk-index-5vatmakn.js +0 -2
  185. package/dist/chunk-index-6yra9kdq.js +0 -2
  186. package/dist/chunk-index-9bdstf8t.js +0 -2
  187. package/dist/chunk-index-acj4xxgq.js +0 -100
  188. package/dist/chunk-index-aexy023z.js +0 -2
  189. package/dist/chunk-index-amxa2bn3.js +0 -2
  190. package/dist/chunk-index-bef0fc2m.js +0 -2
  191. package/dist/chunk-index-c6tphrjr.js +0 -1
  192. package/dist/chunk-index-cn49mawx.js +0 -50
  193. package/dist/chunk-index-gcmmwh8t.js +0 -2
  194. package/dist/chunk-index-h2vhv4kh.js +0 -2
  195. package/dist/chunk-index-h9ccz1r3.js +0 -2
  196. package/dist/chunk-index-n0ejy0wd.js +0 -2
  197. package/dist/chunk-index-nkdg1dfz.js +0 -7
  198. package/dist/chunk-index-qxynr1hf.js +0 -2
  199. package/dist/chunk-index-r7fdgzy1.js +0 -2
  200. package/dist/chunk-index-sdcpf5dp.js +0 -2
  201. package/dist/chunk-index-sw9r4aa4.js +0 -2
  202. package/dist/chunk-index-t5bmsqqd.js +0 -1
  203. package/dist/chunk-index-v8mma5np.js +0 -2
  204. package/dist/chunk-index-vsfsv8hd.js +0 -2
  205. package/dist/chunk-index-wxft0aqb.js +0 -2
  206. package/dist/chunk-index-zvvwxkev.js +0 -2
  207. package/dist/chunk-lang-css-7q1dtpgm.js +0 -1
  208. package/dist/chunk-lang-html-xgvba8rn.js +0 -1
  209. package/dist/chunk-lang-javascript-vgxmgd0a.js +0 -1
  210. package/dist/chunk-lang-typescript-2v652sh5.js +0 -1
  211. package/dist/chunk-layout-actions-bfqtg71d.js +0 -1
  212. package/dist/chunk-menu-items-rztgekfj.js +0 -1
  213. package/dist/chunk-mode-toggle-m5kgb7c7.js +0 -1
  214. package/dist/chunk-notes-a8nqffbf.js +0 -1
  215. package/dist/chunk-notice-bell-rdnfmtf2.js +0 -1
  216. package/dist/chunk-onboarding-wvh0s3vg.js +0 -1
  217. package/dist/chunk-palette-5xtdvy9a.js +0 -1
  218. package/dist/chunk-placement-items-rgyrktvy.js +0 -1
  219. package/dist/chunk-prompt-history-wv0vdsjv.js +0 -1
  220. package/dist/chunk-prompts-75hgb0t0.js +0 -1
  221. package/dist/chunk-room-confirms-bcvhpcry.js +0 -1
  222. package/dist/chunk-rooms-menu-button-ej3a2hyc.js +0 -1
  223. package/dist/chunk-sheet-hosts-d5zbwbbr.js +0 -29
  224. package/dist/chunk-shortcut-help-bh9ntk9s.js +0 -26
  225. package/dist/chunk-shortcuts-6em0atk9.js +0 -1
  226. package/dist/chunk-skill-editor-e43mwz01.js +0 -8
  227. package/dist/chunk-skills-vp107wrp.js +0 -1
  228. package/dist/chunk-start-over-line-xd1v2p0g.js +0 -16
  229. package/dist/chunk-table-svb2xhfd.js +0 -1
  230. package/dist/chunk-thread-actions-926eab0w.js +0 -1
  231. package/dist/chunk-thread-list-j2v1pqkz.js +0 -217
  232. package/dist/chunk-waiting-cdyz7r1g.js +0 -1
  233. package/dist/chunk-waiting-chip-58ttx9av.js +0 -28
  234. package/dist/chunk-where-9jp6g029.js +0 -1
  235. package/dist/chunk-workspace-d34njy4p.js +0 -578
  236. package/dist/chunk-zh-tw-y3jb20wc.js +0 -1
@@ -0,0 +1,115 @@
1
+ import type { ChannelKey, ConversationPort } from "pi-roundtable";
2
+ import {
3
+ type ClientFrame,
4
+ MAX_MESSAGE_CHARS,
5
+ type RefusalCode,
6
+ } from "../api-types.ts";
7
+ import type { Answerer } from "./desk.ts";
8
+ import { coreMessageId } from "./queued-delivery.ts";
9
+ import type { Rooms } from "./rooms.ts";
10
+ import type { AppSurface } from "./surface.ts";
11
+
12
+ export interface QueuedFrameDeps {
13
+ rooms: Pick<Rooms, "resolve">;
14
+ surface: Pick<AppSurface, "delivery" | "edit">;
15
+ conversations: Pick<ConversationPort, "cancelQueued" | "editQueued">;
16
+ }
17
+
18
+ /**
19
+ * An owner's request to edit or cancel a message of theirs that waits for its turn. The room must
20
+ * be one of the app's, the message theirs, and the host must still hold it in the queue: once its
21
+ * turn has started the answer is `not_queued`, and nothing changes. An edit shows the new text in
22
+ * the room as an edited message; a cancel keeps the message, marked cancelled.
23
+ */
24
+ export async function changeQueued(
25
+ frame: Extract<ClientFrame, { type: "edit_queued" | "cancel_queued" }>,
26
+ who: Answerer,
27
+ deps: QueuedFrameDeps,
28
+ ): Promise<RefusalCode | undefined> {
29
+ if (who.tier !== "owner") return "forbidden";
30
+ const room = await deps.rooms.resolve(frame.room);
31
+ if (!room) return "unknown_room";
32
+ if (room.readOnly) return "read_only";
33
+ return inTurn(deps.surface, room.key, () =>
34
+ apply(frame, who, deps, room.key),
35
+ );
36
+ }
37
+
38
+ /** The changes of one room per surface, each waiting for the one before it. */
39
+ const lines = new WeakMap<
40
+ QueuedFrameDeps["surface"],
41
+ Map<string, Promise<unknown>>
42
+ >();
43
+
44
+ /**
45
+ * Runs `change` after every change already asked for in the room has finished. The host takes an
46
+ * edit at once but the room's log is written a moment later; two edits from two sockets would
47
+ * otherwise land in the log in either order, and the room would show a text the turn does not use.
48
+ */
49
+ function inTurn<T>(
50
+ owner: QueuedFrameDeps["surface"],
51
+ room: string,
52
+ change: () => Promise<T>,
53
+ ): Promise<T> {
54
+ let rooms = lines.get(owner);
55
+ if (!rooms) {
56
+ rooms = new Map();
57
+ lines.set(owner, rooms);
58
+ }
59
+ const before = rooms.get(room) ?? Promise.resolve();
60
+ const result = before.then(change, change);
61
+ const tail = result.catch(() => undefined);
62
+ rooms.set(room, tail);
63
+ // A room with nothing waiting leaves no entry behind.
64
+ tail.then(() => {
65
+ if (rooms.get(room) === tail) rooms.delete(room);
66
+ });
67
+ return result;
68
+ }
69
+
70
+ async function apply(
71
+ frame: Extract<ClientFrame, { type: "edit_queued" | "cancel_queued" }>,
72
+ who: Answerer,
73
+ deps: QueuedFrameDeps,
74
+ roomKey: ChannelKey,
75
+ ): Promise<RefusalCode | undefined> {
76
+ const room = { key: roomKey };
77
+ const stored = await deps.surface.delivery.stored(room.key, frame.message);
78
+ if (stored?.kind !== "owner") return "not_queued";
79
+ if (stored.principalId !== who.principalId) return "forbidden";
80
+ const by = { principalId: who.principalId };
81
+ const messageId = coreMessageId(stored.id);
82
+ if (frame.type === "cancel_queued")
83
+ return refusalOf(
84
+ await deps.surface.delivery.cancel(
85
+ room.key,
86
+ stored.id,
87
+ () =>
88
+ deps.conversations.cancelQueued?.(room.key, messageId, by) ??
89
+ "unknown",
90
+ ),
91
+ );
92
+ const text = frame.text.trim();
93
+ if (text.length > MAX_MESSAGE_CHARS) return "too_long";
94
+ if (text === "" && stored.files.length === 0) return "empty";
95
+ const change =
96
+ deps.conversations.editQueued?.(room.key, messageId, by, text) ?? "unknown";
97
+ if (change === "done")
98
+ await deps.surface.edit(room.key, stored.id, text, true);
99
+ return refusalOf(change);
100
+ }
101
+
102
+ function refusalOf(
103
+ change: "done" | "unknown" | "forbidden" | "refused",
104
+ ): RefusalCode | undefined {
105
+ switch (change) {
106
+ case "done":
107
+ return undefined;
108
+ case "unknown":
109
+ return "not_queued";
110
+ case "forbidden":
111
+ return "forbidden";
112
+ case "refused":
113
+ return "not_accepted";
114
+ }
115
+ }
@@ -8,7 +8,7 @@ import {
8
8
  type RoomMessage,
9
9
  START_OVER_TEXT,
10
10
  } from "../api-types.ts";
11
- import { storedJson } from "../host/stored-json.ts";
11
+ import { jsonValuesMigration, storedJson } from "../host/stored-json.ts";
12
12
 
13
13
  /** A message to store; the log numbers it and stamps its time. */
14
14
  export interface NewRoomMessage {
@@ -49,6 +49,7 @@ interface Row {
49
49
  edited_at: Date | null;
50
50
  note: boolean | null;
51
51
  intercom?: RoomIntercom | string | null;
52
+ delivery: "steered" | "cancelled" | null;
52
53
  }
53
54
 
54
55
  /** The most messages one page of history holds. */
@@ -78,6 +79,7 @@ function messageOf(row: Row): RoomMessage {
78
79
  ...(row.intercom == null
79
80
  ? {}
80
81
  : { intercom: storedJson(row.intercom, "intercom card") }),
82
+ ...(row.delivery === null ? {} : { delivery: row.delivery }),
81
83
  };
82
84
  }
83
85
 
@@ -94,6 +96,13 @@ export class RoomLog {
94
96
  this.#sql = sql;
95
97
  }
96
98
 
99
+ /** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
100
+ static readonly jsonValuesMigration: Migration = jsonValuesMigration(
101
+ "web-room-messages-json-values",
102
+ "web_room_messages",
103
+ ["files", "picture", "intercom"],
104
+ );
105
+
97
106
  /** The log's table. */
98
107
  static readonly migration: Migration = {
99
108
  name: "web-room-messages",
@@ -152,6 +161,14 @@ export class RoomLog {
152
161
  },
153
162
  };
154
163
 
164
+ /** The column that records how an owner message sent during a turn went: steered into it, or cancelled while it waited. */
165
+ static readonly deliveryMigration: Migration = {
166
+ name: "web-room-messages-delivery",
167
+ up: async (sql) => {
168
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS delivery text`;
169
+ },
170
+ };
171
+
155
172
  async append(message: NewRoomMessage): Promise<RoomMessage> {
156
173
  const [row] = await this.#insert(message, false);
157
174
  if (!row) throw new Error("the room log stored nothing");
@@ -184,18 +201,18 @@ export class RoomLog {
184
201
  INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
185
202
  author_kind, author_label, picture, reference, created_at, note, intercom)
186
203
  VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
187
- ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
188
- ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
189
- ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
204
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
205
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
206
+ ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
190
207
  ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
191
208
  RETURNING *`
192
209
  : await this.#sql`
193
210
  INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
194
211
  author_kind, author_label, picture, reference, created_at, note, intercom)
195
212
  VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
196
- ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
197
- ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
198
- ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
213
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
214
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
215
+ ${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
199
216
  RETURNING *`;
200
217
  }
201
218
 
@@ -219,6 +236,21 @@ export class RoomLog {
219
236
  return row && messageOf(row);
220
237
  }
221
238
 
239
+ /**
240
+ * Records how a message sent during a turn went (`steered`, `cancelled`), or clears it; the
241
+ * message as it is now, or undefined when the room holds none.
242
+ */
243
+ async setDelivery(
244
+ room: string,
245
+ id: number,
246
+ delivery: "steered" | "cancelled" | null,
247
+ ): Promise<RoomMessage | undefined> {
248
+ const [row]: Row[] = await this.#sql`
249
+ UPDATE web_room_messages SET delivery = ${delivery}
250
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
251
+ return row && messageOf(row);
252
+ }
253
+
222
254
  /** The message the host's reference names, if the room holds one. */
223
255
  async byReference(
224
256
  room: string,
package/src/app/rooms.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  channelKey,
14
14
  characterOf,
15
15
  groupRoomKey,
16
+ helperSurface,
16
17
  isHelper,
17
18
  roomOf,
18
19
  } from "pi-roundtable";
@@ -74,6 +75,8 @@ const THREAD_ID = /^thread\.[0-9a-f-]{36}$/;
74
75
  */
75
76
  export class Rooms {
76
77
  readonly #options: RoomsOptions;
78
+ /** The rooms of temp knights made on Discord that someone opened by their address here. */
79
+ readonly #opened = new Set<string>();
77
80
 
78
81
  constructor(options: RoomsOptions) {
79
82
  this.#options = options;
@@ -204,6 +207,19 @@ export class Rooms {
204
207
  };
205
208
  }
206
209
 
210
+ /**
211
+ * The view of a temp knight's room that the list leaves out, opened by its address: from then on
212
+ * the list shows it. Undefined for any other room.
213
+ */
214
+ async openHelper(key: ChannelKey): Promise<RoomView | undefined> {
215
+ const room = await this.resolve(key);
216
+ if (room?.kind !== "agent") return undefined;
217
+ const agent = this.#options.directory.agent(room.name);
218
+ if (helperSurface(agent) !== "discord") return undefined;
219
+ this.#opened.add(room.name);
220
+ return (await this.list()).find((one) => one.key === key);
221
+ }
222
+
207
223
  async list(): Promise<RoomView[]> {
208
224
  const { surface, directory, modes, mutes, activity, log, threads } =
209
225
  this.#options;
@@ -274,9 +290,21 @@ export class Rooms {
274
290
  // Home first; the others keep the team's order.
275
291
  rooms.sort((a, b) => Number(b.home) - Number(a.home));
276
292
  const last = await log.lastOf(rooms.map((room) => room.key));
277
- return rooms.map((room) => {
293
+ // A temp knight made on Discord works there: its room here shows once it has a message or
294
+ // someone opened it by its address.
295
+ const quiet = new Set<string>(
296
+ agents
297
+ .filter(
298
+ (agent) =>
299
+ helperSurface(agent) === "discord" && !this.#opened.has(agent.name),
300
+ )
301
+ .map((agent) => agentRoomKey(surface, agent.name))
302
+ .filter((key) => !last.has(key)),
303
+ );
304
+ return rooms.flatMap((room) => {
305
+ if (quiet.has(room.key)) return [];
278
306
  const message = last.get(room.key);
279
- return message ? { ...room, last: message } : room;
307
+ return [message ? { ...room, last: message } : room];
280
308
  });
281
309
  }
282
310
  }
package/src/app/routes.ts CHANGED
@@ -60,6 +60,7 @@ import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
60
60
  import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
61
61
  import { type ResolvedRoom, type Rooms, roomModeOf } from "./rooms.ts";
62
62
  import { SkillDocApi } from "./skill-doc-routes.ts";
63
+ import { SkillGroupApi } from "./skill-group-routes.ts";
63
64
  import type { AppSurface } from "./surface.ts";
64
65
  import { TeamApi } from "./team-routes.ts";
65
66
  import type { WebThreads } from "./threads.ts";
@@ -70,15 +71,40 @@ import {
70
71
  type WorkspaceFiles,
71
72
  WorkspaceRefusal,
72
73
  } from "./workspace-files.ts";
74
+ import { uniqueNames, zipStore } from "./zip.ts";
73
75
 
74
76
  /** The most threads one request puts back on the room list: more than the list ever shows. */
75
77
  const MAX_RESTORED = 100;
78
+ /** The most bytes a message's files may add up to and still be zipped together: 200 MB. */
79
+ const MAX_ZIP_BYTES = 200 * 1024 * 1024;
80
+
81
+ /** A message's zip, named after when it was sent on the host's clock: `attachments-20261031-2030.zip`. */
82
+ export const zipName = (at: Date, timeZone = "UTC"): string => {
83
+ if (Number.isNaN(at.getTime())) return "attachments.zip";
84
+ const part = Object.fromEntries(
85
+ new Intl.DateTimeFormat("en-CA", {
86
+ timeZone,
87
+ year: "numeric",
88
+ month: "2-digit",
89
+ day: "2-digit",
90
+ hour: "2-digit",
91
+ minute: "2-digit",
92
+ hourCycle: "h23",
93
+ })
94
+ .formatToParts(at)
95
+ .map(({ type, value }) => [type, value]),
96
+ );
97
+ return `attachments-${part.year}${part.month}${part.day}-${part.hour}${part.minute}.zip`;
98
+ };
99
+
76
100
  /** The most rooms one bulk request names. */
77
101
  const MAX_BULK_ROOMS = MAX_READ_ALL;
78
102
 
79
103
  export interface AppApiOptions {
80
104
  /** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
81
105
  presentation?: ConsolePresentation;
106
+ /** The host's IANA time zone, for the date in a zip's name; absent = UTC. */
107
+ timeZone?: string;
82
108
  surface: AppSurface;
83
109
  rooms: Rooms;
84
110
  log: Pick<RoomLog, "page" | "lastOf" | "removeRoom" | "get" | "startOver">;
@@ -142,6 +168,8 @@ export interface AppApiOptions {
142
168
  | "documentHistory"
143
169
  | "delete"
144
170
  >;
171
+ /** The registry's skill groups: listed, saved and deleted. */
172
+ groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
145
173
  };
146
174
  /** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
147
175
  prompts?: PromptApiOptions["prompts"];
@@ -285,6 +313,7 @@ export class AppApi {
285
313
  readonly #helpers: HelperRoutes;
286
314
  readonly #prompts: PromptApi | undefined;
287
315
  readonly #skillDocs: SkillDocApi | undefined;
316
+ readonly #skillGroups: SkillGroupApi | undefined;
288
317
 
289
318
  constructor(options: AppApiOptions) {
290
319
  this.#options = options;
@@ -311,6 +340,17 @@ export class AppApi {
311
340
  catalog: () => options.skills?.catalog() ?? [],
312
341
  })
313
342
  : undefined;
343
+ const skills = options.skills;
344
+ this.#skillGroups = skills
345
+ ? new SkillGroupApi({
346
+ groups: skills.groups,
347
+ catalog: () => skills.catalog(),
348
+ attach: (agent, add, remove) => skills.attach(agent, add, remove),
349
+ active: (agent) =>
350
+ options.directory.agent(agent)?.status === "active",
351
+ changed: () => options.changed(),
352
+ })
353
+ : undefined;
314
354
  this.#team = new TeamApi({
315
355
  surface: options.surface.surface,
316
356
  rooms: options.rooms,
@@ -394,6 +434,7 @@ export class AppApi {
394
434
  "workspace",
395
435
  "prompts",
396
436
  "skill-docs",
437
+ "skill-groups",
397
438
  ].includes(second ?? ""));
398
439
  if (!mine) return undefined;
399
440
  try {
@@ -473,6 +514,8 @@ export class AppApi {
473
514
  throw notFound();
474
515
  return json({ ok: true });
475
516
  }
517
+ if (area === "files" && method === "GET" && id === "zip" && !action)
518
+ return await this.#messageZip(request, admission);
476
519
  if (area === "files" && method === "GET" && id && !action)
477
520
  return await this.#file(id, admission);
478
521
  if (area === "workspace") return this.#workspace(request, parts, admission);
@@ -492,6 +535,11 @@ export class AppApi {
492
535
  throw new HttpError(404, "This host has no skills.");
493
536
  return this.#skillDocs.handle(request, parts, owner);
494
537
  }
538
+ if (area === "skill-groups") {
539
+ if (!this.#skillGroups)
540
+ throw new HttpError(404, "This host has no skills.");
541
+ return this.#skillGroups.handle(request, parts, owner);
542
+ }
495
543
  throw new HttpError(404, "There is no such API.");
496
544
  }
497
545
 
@@ -565,8 +613,11 @@ export class AppApi {
565
613
  const room = await rooms.resolve(key);
566
614
  if (!room) throw notFound();
567
615
  if (!action && method === "GET") {
568
- // A thread dismissed from the list still opens by its address; the page asks for its view.
569
- const view = await rooms.threadView(room.key);
616
+ // A thread dismissed from the list, or a temp knight's room the list leaves out, still opens
617
+ // by its address; the page asks for its view.
618
+ const view =
619
+ (await rooms.threadView(room.key)) ??
620
+ (await rooms.openHelper(room.key));
570
621
  if (!view) throw notFound();
571
622
  return json({ room: view } satisfies RoomOneView);
572
623
  }
@@ -609,7 +660,10 @@ export class AppApi {
609
660
  const cursor = await reads?.cursorOf(room.key, self);
610
661
  return json({
611
662
  ...page,
612
- messages: withExpiredFiles(page.messages, this.#options.files),
663
+ messages: withExpiredFiles(
664
+ page.messages.map((one) => this.#options.surface.delivery.shown(one)),
665
+ this.#options.files,
666
+ ),
613
667
  cards: await cards.during(room.key, {
614
668
  ...(first && (page.more || isStartOver(first))
615
669
  ? { from: first.at }
@@ -843,6 +897,16 @@ export class AppApi {
843
897
 
844
898
  async #file(id: string, admission: Authenticated): Promise<Response> {
845
899
  if (!isFileId(id)) throw notFound();
900
+ await this.#mayServe(id, admission);
901
+ const { files } = this.#options;
902
+ const meta = files.meta(id);
903
+ const blob = files.blob(id);
904
+ if (!meta || !blob) throw notFound();
905
+ return fileResponse(meta, blob);
906
+ }
907
+
908
+ /** Refuses, as a missing file, a stored file the visitor may not be served. */
909
+ async #mayServe(id: string, admission: Authenticated): Promise<void> {
846
910
  const { files, host } = this.#options;
847
911
  // A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
848
912
  const bound = files.binding(id)?.room;
@@ -853,10 +917,62 @@ export class AppApi {
853
917
  !(await host.mayRead(viewerOf(admission.visitor), bound))
854
918
  )
855
919
  throw notFound();
856
- const meta = files.meta(id);
857
- const blob = files.blob(id);
858
- if (!meta || !blob) throw notFound();
859
- return fileResponse(meta, blob);
920
+ }
921
+
922
+ /**
923
+ * `GET api/files/zip?room=<room>&message=<id>`: the message's files still kept, as one zip.
924
+ * The room and every file answer as the single file does; a refusal is a missing message.
925
+ */
926
+ async #messageZip(
927
+ request: Request,
928
+ admission: Authenticated,
929
+ ): Promise<Response> {
930
+ const params = new URL(request.url).searchParams;
931
+ const room = params.get("room") ?? "";
932
+ const id = Number(params.get("message"));
933
+ if (room === "" || !Number.isSafeInteger(id) || id <= 0) throw notFound();
934
+ const { files, host, log } = this.#options;
935
+ const mayReadRoom =
936
+ admission.visitor.tier === "owner" ||
937
+ (host !== undefined &&
938
+ (await host.mayRead(viewerOf(admission.visitor), room)));
939
+ if (!mayReadRoom) throw notFound();
940
+ const message = await log.get(room, id);
941
+ if (!message) throw notFound();
942
+ // Only the files still kept: a swept file has no binding left to check it by.
943
+ const kept = message.files.flatMap((file) => {
944
+ const meta = isFileId(file.id) ? files.meta(file.id) : undefined;
945
+ const blob = meta && files.blob(file.id);
946
+ return meta && blob ? [{ id: file.id, meta, blob }] : [];
947
+ });
948
+ for (const file of kept) await this.#mayServe(file.id, admission);
949
+ if (kept.length === 0)
950
+ throw new HttpError(
951
+ 404,
952
+ "None of this message's files are kept any more.",
953
+ );
954
+ const total = kept.reduce((sum, { blob }) => sum + blob.size, 0);
955
+ if (total > MAX_ZIP_BYTES)
956
+ throw new HttpError(
957
+ 413,
958
+ "These files are too large to download together; download them one by one.",
959
+ );
960
+ const names = uniqueNames(kept.map(({ meta }) => meta.name));
961
+ const entries = await Promise.all(
962
+ kept.map(async ({ blob }, i) => ({
963
+ name: names[i] ?? "file",
964
+ data: new Uint8Array(await blob.arrayBuffer()),
965
+ })),
966
+ );
967
+ const at = new Date(message.at);
968
+ return new Response(zipStore(entries, at), {
969
+ headers: {
970
+ "content-type": "application/zip",
971
+ "content-disposition": `attachment; filename="${zipName(at, this.#options.timeZone)}"`,
972
+ "cache-control": "private, no-store",
973
+ "x-content-type-options": "nosniff",
974
+ },
975
+ });
860
976
  }
861
977
 
862
978
  /** The workspace files pane's API; every call, a read too, is an owner's. */
@@ -0,0 +1,126 @@
1
+ import type { SkillRegistry } from "pi-roundtable";
2
+ import type {
3
+ SkillGroupApplied,
4
+ SkillGroupApply,
5
+ SkillGroupChanged,
6
+ SkillGroupSave,
7
+ SkillGroupsView,
8
+ SkillView,
9
+ } from "../api-types.ts";
10
+ import { json } from "../http.ts";
11
+ import {
12
+ bad,
13
+ bodyOf,
14
+ HttpError,
15
+ optionalList,
16
+ optionalText,
17
+ requiredText,
18
+ } from "./api-http.ts";
19
+ import type { OwnerOf } from "./team-routes.ts";
20
+
21
+ export interface SkillGroupApiOptions {
22
+ groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
23
+ /** The catalog the page lists: who carries each skill, and which are built in. */
24
+ catalog(): SkillView[];
25
+ /** The same change the per-skill toggle makes. */
26
+ attach(
27
+ agent: string,
28
+ add: readonly string[],
29
+ remove: readonly string[],
30
+ ): Promise<string[]>;
31
+ /** Whether a knight answers; an archived or unknown one is a 404. */
32
+ active(agent: string): boolean;
33
+ changed(): void;
34
+ }
35
+
36
+ /**
37
+ * The app's REST for skill groups: listing them, creating or replacing one, deleting one, and
38
+ * applying one to a knight. Applying switches on the group's skills the knight lacks through the
39
+ * per-skill toggle's path; applying it off switches off the group's skills the knight carries, built-in
40
+ * ones excepted. The knight carries skills, not the group.
41
+ * Every call is an owner's; the registry's refusals are a 400 in its words.
42
+ */
43
+ export class SkillGroupApi {
44
+ readonly #options: SkillGroupApiOptions;
45
+
46
+ constructor(options: SkillGroupApiOptions) {
47
+ this.#options = options;
48
+ }
49
+
50
+ async handle(
51
+ request: Request,
52
+ parts: string[],
53
+ owner: OwnerOf,
54
+ ): Promise<Response> {
55
+ const { method } = request;
56
+ const [, , name, action] = parts;
57
+ const { groups } = this.#options;
58
+ // The tier is read first, so a person below the owner learns of no group.
59
+ await owner();
60
+ if (!name) {
61
+ if (method !== "GET") throw new HttpError(405, "Method not allowed.");
62
+ return json({
63
+ groups: groups.groups().map(({ name, description, skills }) => ({
64
+ name,
65
+ description,
66
+ skills: [...skills],
67
+ })),
68
+ } satisfies SkillGroupsView);
69
+ }
70
+ const group = groups.groups().find((one) => one.name === name);
71
+ if (!action && method === "PUT") {
72
+ const body = await bodyOf(request);
73
+ const from = optionalText(body, "from");
74
+ const save: SkillGroupSave = {
75
+ description: requiredText(body, "description"),
76
+ skills: optionalList(body, "skills") ?? [],
77
+ ...(from ? { from } : {}),
78
+ };
79
+ const renamed = save.from !== undefined && save.from !== name;
80
+ if (renamed) {
81
+ if (!groups.groups().some((one) => one.name === save.from))
82
+ throw new HttpError(404, "There is no such skill group.");
83
+ if (group) throw bad(`There is already a skill group "${name}".`);
84
+ }
85
+ let message = await groups.setGroup(name, save.description, save.skills);
86
+ if (renamed && save.from)
87
+ message = `${message} ${await groups.deleteGroup(save.from)}`;
88
+ this.#options.changed();
89
+ return json({ message } satisfies SkillGroupChanged, group ? 200 : 201);
90
+ }
91
+ if (!group) throw new HttpError(404, "There is no such skill group.");
92
+ if (!action && method === "DELETE") {
93
+ const message = await groups.deleteGroup(name);
94
+ this.#options.changed();
95
+ return json({ message } satisfies SkillGroupChanged);
96
+ }
97
+ if (action === "apply" && method === "POST" && parts.length === 4) {
98
+ const body = await bodyOf(request);
99
+ const apply: SkillGroupApply = {
100
+ agent: requiredText(body, "agent"),
101
+ on: body.on !== false,
102
+ };
103
+ if (!this.#options.active(apply.agent))
104
+ throw new HttpError(404, "There is no such room, knight or file.");
105
+ const catalog = new Map(
106
+ this.#options.catalog().map((entry) => [entry.name, entry]),
107
+ );
108
+ // A built-in skill is carried always: it is never added nor switched off.
109
+ const switchable = group.skills.filter(
110
+ (skill) => catalog.get(skill)?.source.kind !== "builtin",
111
+ );
112
+ const carries = (skill: string) =>
113
+ catalog.get(skill)?.carriers.includes(apply.agent) ?? false;
114
+ const added = apply.on
115
+ ? switchable.filter((skill) => !carries(skill))
116
+ : [];
117
+ const removed = apply.on ? [] : switchable.filter(carries);
118
+ if (added.length > 0 || removed.length > 0) {
119
+ await this.#options.attach(apply.agent, added, removed);
120
+ this.#options.changed();
121
+ }
122
+ return json({ added, removed } satisfies SkillGroupApplied);
123
+ }
124
+ throw new HttpError(404, "There is no such API.");
125
+ }
126
+ }
package/src/app/socket.ts CHANGED
@@ -26,6 +26,8 @@ import type { PluggedFrames, Viewer, WebAppRegistry } from "../web-app.ts";
26
26
  import type { Answerer, OwnerDesk } from "./desk.ts";
27
27
  import type { Presence } from "./presence.ts";
28
28
  import { parseClientFrame } from "./protocol.ts";
29
+ import { coreMessageId } from "./queued-delivery.ts";
30
+ import { changeQueued } from "./queued-frames.ts";
29
31
  import type { Rooms } from "./rooms.ts";
30
32
  import type { AppSocket, Audience, Sockets } from "./sockets.ts";
31
33
  import type { AppSurface } from "./surface.ts";
@@ -65,7 +67,7 @@ export interface SocketRouteOptions {
65
67
  * not: the pieces of the app send what is per person there, such as the notices and the badge.
66
68
  */
67
69
  opened?(connection: AppSocket): Promise<void>;
68
- conversations: Pick<ConversationPort, "stop">;
70
+ conversations: Pick<ConversationPort, "stop" | "cancelQueued" | "editQueued">;
69
71
  logger: Logger;
70
72
  /**
71
73
  * The longest a socket stays open, whatever its credential says. A password session lives for
@@ -443,6 +445,14 @@ export class SocketRoute {
443
445
  else conversations.stop(room.key);
444
446
  break;
445
447
  }
448
+ case "edit_queued":
449
+ case "cancel_queued":
450
+ refused = await changeQueued(frame, who, {
451
+ rooms,
452
+ surface: this.#options.surface,
453
+ conversations,
454
+ });
455
+ break;
446
456
  case "approval":
447
457
  case "answer":
448
458
  case "answers":
@@ -658,7 +668,7 @@ export class SocketRoute {
658
668
  stored?.(message);
659
669
  const inbound: InboundMessage = {
660
670
  channel: room,
661
- messageId: `app-${message.id}`,
671
+ messageId: coreMessageId(message.id),
662
672
  actor: who.actor,
663
673
  authorId: who.principalId,
664
674
  authorName: who.name,