pi-roundtable-web 0.25.0 → 0.26.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 (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { QUESTIONNAIRE_LIMITS } from "pi-roundtable";
2
2
  import {
3
3
  type ClientFrame,
4
+ type FormInput,
4
5
  MAX_MESSAGE_FILES,
5
6
  type PromptAnswer,
6
7
  } from "../api-types.ts";
@@ -40,6 +41,31 @@ function parseAnswer(value: unknown): PromptAnswer | undefined {
40
41
  };
41
42
  }
42
43
 
44
+ /** The most fields and the longest value a form's input may carry. */
45
+ const FORM_FIELDS = 40;
46
+ const FORM_VALUE_CHARS = 32_000;
47
+
48
+ /** A form's input: text, or a list of text, by field name. */
49
+ function parseInput(value: unknown): FormInput | undefined {
50
+ if (!isRecord(value)) return undefined;
51
+ const entries = Object.entries(value);
52
+ if (entries.length > FORM_FIELDS) return undefined;
53
+ const input: Record<string, string | string[]> = {};
54
+ for (const [name, field] of entries) {
55
+ if (name.length === 0 || name.length > ID_CHARS) return undefined;
56
+ if (typeof field === "string" && field.length <= FORM_VALUE_CHARS)
57
+ input[name] = field;
58
+ else if (
59
+ Array.isArray(field) &&
60
+ field.length <= FORM_FIELDS &&
61
+ field.every((one) => isText(one) && one.length <= FORM_VALUE_CHARS)
62
+ )
63
+ input[name] = field as string[];
64
+ else return undefined;
65
+ }
66
+ return input;
67
+ }
68
+
43
69
  function parseAnswers(value: Record<string, unknown>): ClientFrame | undefined {
44
70
  const { prompt, answers, note } = value;
45
71
  if (!isId(prompt) || !Array.isArray(answers)) return undefined;
@@ -99,8 +125,10 @@ export function parseClientFrame(
99
125
  ...(value.room === undefined ? {} : { room: value.room as string }),
100
126
  };
101
127
  case "send": {
102
- const { id, room, text, uploads } = value;
128
+ const { id, room, text, uploads, mode } = value;
103
129
  if (!isId(id) || !isId(room) || !isText(text)) return undefined;
130
+ if (mode !== undefined && mode !== "note" && mode !== "turn")
131
+ return undefined;
104
132
  if (
105
133
  uploads !== undefined &&
106
134
  !(
@@ -118,8 +146,42 @@ export function parseClientFrame(
118
146
  ...(uploads === undefined || uploads.length === 0
119
147
  ? {}
120
148
  : { uploads: uploads as string[] }),
149
+ ...(mode === undefined ? {} : { mode }),
121
150
  };
122
151
  }
152
+ case "subscribe":
153
+ case "unsubscribe":
154
+ return isId(value.room)
155
+ ? { type: value.type, room: value.room }
156
+ : undefined;
157
+ case "act": {
158
+ const { id, room, page, action, card, input } = value;
159
+ if (!isId(id) || !isId(action)) return undefined;
160
+ // One target: a room or a page, never both and never neither.
161
+ if ((room === undefined) === (page === undefined)) return undefined;
162
+ if (!optional(room, isId) || !optional(page, isId)) return undefined;
163
+ if (!optional(card, isId)) return undefined;
164
+ const parsed = input === undefined ? undefined : parseInput(input);
165
+ if (input !== undefined && !parsed) return undefined;
166
+ return {
167
+ type: "act",
168
+ id,
169
+ action,
170
+ ...(room === undefined ? {} : { room: room as string }),
171
+ ...(page === undefined ? {} : { page: page as string }),
172
+ ...(card === undefined ? {} : { card: card as string }),
173
+ ...(parsed ? { input: parsed } : {}),
174
+ };
175
+ }
176
+ case "ack":
177
+ return isId(value.tag) ? { type: "ack", tag: value.tag } : undefined;
178
+ case "read":
179
+ return isId(value.room) &&
180
+ typeof value.upTo === "number" &&
181
+ Number.isSafeInteger(value.upTo) &&
182
+ value.upTo >= 0
183
+ ? { type: "read", room: value.room, upTo: value.upTo }
184
+ : undefined;
123
185
  case "stop":
124
186
  return isId(value.room) ? { type: "stop", room: value.room } : undefined;
125
187
  case "approval":
@@ -1,6 +1,12 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { Migration } from "pi-roundtable";
3
- import type { RoomFile, RoomMessage } from "../api-types.ts";
3
+ import type {
4
+ HostAuthorKind,
5
+ PictureRef,
6
+ RoomFile,
7
+ RoomMessage,
8
+ } from "../api-types.ts";
9
+ import { storedJson } from "../host/stored-json.ts";
4
10
 
5
11
  /** A message to store; the log numbers it and stamps its time. */
6
12
  export interface NewRoomMessage {
@@ -11,6 +17,16 @@ export interface NewRoomMessage {
11
17
  author: string;
12
18
  text: string;
13
19
  files?: RoomFile[];
20
+ /** Host rooms: who wrote it, as the host's kinds of author name them. */
21
+ authorKind?: HostAuthorKind;
22
+ authorLabel?: string;
23
+ picture?: PictureRef;
24
+ /** Host rooms: the host's own id for the message, unique within the room. */
25
+ reference?: string;
26
+ /** When it was written, for an import; the log's clock otherwise. Retention counts from it. */
27
+ at?: Date;
28
+ /** An internal note: shown to the room's readers, never handed to the agent. */
29
+ note?: true;
14
30
  }
15
31
 
16
32
  interface Row {
@@ -23,16 +39,18 @@ interface Row {
23
39
  text: string;
24
40
  files: RoomFile[] | string;
25
41
  created_at: Date;
42
+ author_kind: HostAuthorKind | null;
43
+ author_label: string | null;
44
+ picture: PictureRef | string | null;
45
+ edited_at: Date | null;
46
+ note: boolean | null;
26
47
  }
27
48
 
28
49
  /** The most messages one page of history holds. */
29
50
  export const PAGE_LIMIT = 100;
30
51
 
31
52
  function messageOf(row: Row): RoomMessage {
32
- const files =
33
- typeof row.files === "string"
34
- ? (JSON.parse(row.files) as RoomFile[])
35
- : row.files;
53
+ const files = storedJson(row.files, "message files");
36
54
  return {
37
55
  id: Number(row.id),
38
56
  room: row.room,
@@ -43,6 +61,15 @@ function messageOf(row: Row): RoomMessage {
43
61
  text: row.text,
44
62
  files,
45
63
  at: row.created_at.toISOString(),
64
+ ...(row.author_kind === null ? {} : { authorKind: row.author_kind }),
65
+ ...(row.author_label === null ? {} : { authorLabel: row.author_label }),
66
+ ...(row.picture === null
67
+ ? {}
68
+ : {
69
+ picture: storedJson(row.picture, "picture"),
70
+ }),
71
+ ...(row.edited_at === null ? {} : { edited: true as const }),
72
+ ...(row.note ? { note: true as const } : {}),
46
73
  };
47
74
  }
48
75
 
@@ -79,29 +106,141 @@ export class RoomLog {
79
106
  },
80
107
  };
81
108
 
109
+ /** The columns a host room's messages add: who wrote it, the host's reference, and when it was changed. */
110
+ static readonly hostMigration: Migration = {
111
+ name: "web-room-messages-host",
112
+ up: async (sql) => {
113
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_kind text`;
114
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS author_label text`;
115
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS picture jsonb`;
116
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS reference text`;
117
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS edited_at timestamptz`;
118
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS web_room_messages_reference ON web_room_messages (room, reference) WHERE reference IS NOT NULL`;
119
+ await sql`CREATE INDEX IF NOT EXISTS web_room_messages_created ON web_room_messages (created_at)`;
120
+ },
121
+ };
122
+
123
+ /** The column that marks a message as an internal note, for the rooms more than one person reads. */
124
+ static readonly noteMigration: Migration = {
125
+ name: "web-room-messages-note",
126
+ up: async (sql) => {
127
+ await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS note boolean NOT NULL DEFAULT false`;
128
+ },
129
+ };
130
+
82
131
  async append(message: NewRoomMessage): Promise<RoomMessage> {
83
- const files = JSON.stringify(message.files ?? []);
84
- const [row]: Row[] = await this.#sql`
85
- INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files)
86
- VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
87
- ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb)
88
- RETURNING *`;
132
+ const [row] = await this.#insert(message, false);
89
133
  if (!row) throw new Error("the room log stored nothing");
90
134
  return messageOf(row);
91
135
  }
92
136
 
93
- /** Changes the text of a message stored in the room; undefined when the room holds no such message. */
137
+ /**
138
+ * Stores a host room's message once per `reference`: a second post with the same reference in
139
+ * the room stores nothing and answers the message already there, with `created` false.
140
+ */
141
+ async appendOnce(
142
+ message: NewRoomMessage,
143
+ ): Promise<{ message: RoomMessage; created: boolean }> {
144
+ const [row] = await this.#insert(message, message.reference !== undefined);
145
+ if (row) return { message: messageOf(row), created: true };
146
+ const [existing]: Row[] = await this.#sql`
147
+ SELECT * FROM web_room_messages
148
+ WHERE room = ${message.room} AND reference = ${message.reference ?? null}`;
149
+ if (!existing) throw new Error("the room log stored nothing");
150
+ return { message: messageOf(existing), created: false };
151
+ }
152
+
153
+ async #insert(message: NewRoomMessage, once: boolean): Promise<Row[]> {
154
+ const files = JSON.stringify(message.files ?? []);
155
+ const picture = message.picture ? JSON.stringify(message.picture) : null;
156
+ const at = message.at ?? new Date();
157
+ return once
158
+ ? await this.#sql`
159
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
160
+ author_kind, author_label, picture, reference, created_at, note)
161
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
162
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
163
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
164
+ ${message.reference ?? null}, ${at}, ${message.note === true})
165
+ ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
166
+ RETURNING *`
167
+ : await this.#sql`
168
+ INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
169
+ author_kind, author_label, picture, reference, created_at, note)
170
+ VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
171
+ ${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
172
+ ${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
173
+ ${message.reference ?? null}, ${at}, ${message.note === true})
174
+ RETURNING *`;
175
+ }
176
+
177
+ /**
178
+ * Changes the text of a message stored in the room; undefined when the room holds no such
179
+ * message. `mark` shows it as edited, as a host's edit of someone's words does.
180
+ */
94
181
  async edit(
95
182
  room: string,
96
183
  id: number,
97
184
  text: string,
185
+ mark = false,
98
186
  ): Promise<RoomMessage | undefined> {
187
+ const [row]: Row[] = mark
188
+ ? await this.#sql`
189
+ UPDATE web_room_messages SET text = ${text}, edited_at = now()
190
+ WHERE id = ${id} AND room = ${room} RETURNING *`
191
+ : await this.#sql`
192
+ UPDATE web_room_messages SET text = ${text}
193
+ WHERE id = ${id} AND room = ${room} RETURNING *`;
194
+ return row && messageOf(row);
195
+ }
196
+
197
+ /** The message the host's reference names, if the room holds one. */
198
+ async byReference(
199
+ room: string,
200
+ reference: string,
201
+ ): Promise<number | undefined> {
202
+ const [row]: { id: string | number | bigint }[] = await this.#sql`
203
+ SELECT id FROM web_room_messages WHERE room = ${room} AND reference = ${reference}`;
204
+ return row === undefined ? undefined : Number(row.id);
205
+ }
206
+
207
+ /** One stored message of the room, or undefined. */
208
+ async get(room: string, id: number): Promise<RoomMessage | undefined> {
209
+ const [row]: Row[] = await this.#sql`
210
+ SELECT * FROM web_room_messages WHERE room = ${room} AND id = ${id}`;
211
+ return row && messageOf(row);
212
+ }
213
+
214
+ /** Deletes one message of the room; the message as it was, or undefined when the room held none. */
215
+ async remove(room: string, id: number): Promise<RoomMessage | undefined> {
99
216
  const [row]: Row[] = await this.#sql`
100
- UPDATE web_room_messages SET text = ${text}
101
- WHERE id = ${id} AND room = ${room} RETURNING *`;
217
+ DELETE FROM web_room_messages WHERE room = ${room} AND id = ${id} RETURNING *`;
102
218
  return row && messageOf(row);
103
219
  }
104
220
 
221
+ /**
222
+ * Deletes the messages older than `before` of the rooms whose key starts with `prefix`, the
223
+ * kind's rooms; the deleted messages, so their files can go too. Age is the message's own time.
224
+ */
225
+ async sweepKind(prefix: string, before: Date): Promise<RoomMessage[]> {
226
+ const rows: Row[] = await this.#sql`
227
+ DELETE FROM web_room_messages
228
+ WHERE starts_with(room, ${prefix}) AND created_at < ${before} RETURNING *`;
229
+ return rows.map(messageOf);
230
+ }
231
+
232
+ /** The newest message id of each room that has one, for the read cursors. */
233
+ async newestIds(rooms: readonly string[]): Promise<Map<string, number>> {
234
+ const found = new Map<string, number>();
235
+ if (rooms.length === 0) return found;
236
+ const rows: { room: string; id: string | number | bigint }[] = await this
237
+ .#sql`
238
+ SELECT room, max(id) AS id FROM web_room_messages
239
+ WHERE room IN ${this.#sql(rooms)} GROUP BY room`;
240
+ for (const row of rows) found.set(row.room, Number(row.id));
241
+ return found;
242
+ }
243
+
105
244
  /** The newest `limit` messages older than `before` (all of them when absent), oldest first. */
106
245
  async page(
107
246
  room: string,
package/src/app/rooms.ts CHANGED
@@ -45,6 +45,11 @@ export interface RoomsOptions {
45
45
  activity(
46
46
  room: ChannelKey,
47
47
  ): Pick<RoomView, "typing" | "stoppable" | "phase" | "agent">;
48
+ /**
49
+ * Whether more than one person reads the agent and group rooms, so a message may be an
50
+ * internal note. Absent: one person does, and a room takes only turns.
51
+ */
52
+ readers?(): Promise<boolean>;
48
53
  }
49
54
 
50
55
  const THREAD_ID = /^thread\.[0-9a-f-]{36}$/;
@@ -111,6 +116,11 @@ export class Rooms {
111
116
  : undefined;
112
117
  }
113
118
 
119
+ /** Whether the agent and group rooms take internal notes: more than one person reads them. */
120
+ async notesOn(): Promise<boolean> {
121
+ return (await this.#options.readers?.()) === true;
122
+ }
123
+
114
124
  /** The home room: the coordinator's, else the first active agent's. */
115
125
  homeKey(): ChannelKey | undefined {
116
126
  const { directory, team, surface } = this.#options;
@@ -153,6 +163,33 @@ export class Rooms {
153
163
  return `${this.#options.mount}${path ?? "/avatars/missing.png"}`;
154
164
  }
155
165
 
166
+ /**
167
+ * The view of one thread, listed or not: a thread the owners dismissed is off the list and still
168
+ * opens by its address, and the page asks for its view here.
169
+ */
170
+ async threadView(key: ChannelKey): Promise<RoomView | undefined> {
171
+ const room = await this.resolve(key);
172
+ if (room?.kind !== "thread") return undefined;
173
+ const thread = await this.#options.threads.get(room.name);
174
+ if (!thread) return undefined;
175
+ const { modes, activity, log } = this.#options;
176
+ const { yolo, longRunning } = modes.peek(key);
177
+ const last = (await log.lastOf([key])).get(key);
178
+ return {
179
+ home: false,
180
+ readOnly: true,
181
+ mode: { yolo, longRunning },
182
+ ...activity(key),
183
+ kind: "thread",
184
+ name: thread.id,
185
+ displayName: thread.title,
186
+ parent: thread.parent,
187
+ ...(thread.open ? {} : { closed: true as const }),
188
+ ...(last ? { last } : {}),
189
+ key,
190
+ };
191
+ }
192
+
156
193
  async list(): Promise<RoomView[]> {
157
194
  const { surface, directory, modes, mutes, activity, log, threads } =
158
195
  this.#options;
@@ -162,6 +199,7 @@ export class Rooms {
162
199
  const groups = directory.groups().filter((g) => g.status === "active");
163
200
  const shown = await threads.list();
164
201
  const muted = await mutes.all();
202
+ const notes = await this.notesOn();
165
203
  const view = (
166
204
  key: ChannelKey,
167
205
  rest: Pick<RoomView, "kind" | "name" | "displayName"> & Partial<RoomView>,
@@ -172,6 +210,7 @@ export class Rooms {
172
210
  readOnly: false,
173
211
  mode: { yolo, longRunning },
174
212
  ...(muted.has(key) ? { muted: true as const } : {}),
213
+ ...(notes && rest.kind !== "thread" ? { notes: true as const } : {}),
175
214
  ...activity(key),
176
215
  ...rest,
177
216
  key,