pi-roundtable-web 0.26.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 (198) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +23 -7
  3. package/dist/chunk-account-ghq105t4.js +1 -0
  4. package/dist/{chunk-action-menu-rm5cyqkj.js → chunk-action-menu-me0g9qsc.js} +1 -1
  5. package/dist/{chunk-agent-b017vswv.js → chunk-agent-rdv7ekrt.js} +1 -1
  6. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  7. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  8. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  9. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  10. package/dist/{chunk-connectors-hrm06nx8.js → chunk-connectors-bkv3xn83.js} +1 -1
  11. package/dist/{chunk-conversations-e26rrf01.js → chunk-conversations-0e0vbxwr.js} +1 -1
  12. package/dist/{chunk-drag-v41gx2mc.js → chunk-drag-peyb8t0k.js} +1 -1
  13. package/dist/{chunk-entry-jxxb3ath.js → chunk-entry-mv7q8br0.js} +1 -1
  14. package/dist/chunk-folder-list-era3tksh.js +1 -0
  15. package/dist/chunk-folds-hvws0bd5.js +1 -0
  16. package/dist/{chunk-gate-8k61stsc.js → chunk-gate-hc7dd0fx.js} +1 -1
  17. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  18. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  19. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  20. package/dist/chunk-host-picture-014yzk20.js +1 -0
  21. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  22. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  23. package/dist/chunk-index-0fmmvgee.js +2 -0
  24. package/dist/chunk-index-0q15ysrb.js +1 -0
  25. package/dist/chunk-index-0xh24y25.js +2 -0
  26. package/dist/chunk-index-2yebd9tb.js +426 -0
  27. package/dist/chunk-index-4t0r3t3b.js +2 -0
  28. package/dist/chunk-index-5yzfrf7b.js +138 -0
  29. package/dist/{chunk-index-2d0kg096.js → chunk-index-6twb7ccd.js} +2 -2
  30. package/dist/chunk-index-73gh7kgr.js +920 -0
  31. package/dist/chunk-index-7xffskfa.js +8 -0
  32. package/dist/chunk-index-aqw7cy5j.js +1 -0
  33. package/dist/{chunk-index-d1prgfbp.js → chunk-index-b6ca02e8.js} +2 -2
  34. package/dist/chunk-index-b8nvhcx5.js +2 -0
  35. package/dist/{chunk-index-014rjv9k.js → chunk-index-cvbdf2ca.js} +2 -2
  36. package/dist/chunk-index-czdketze.js +2 -0
  37. package/dist/{chunk-index-y8rnq8zf.js → chunk-index-dt9775ka.js} +3 -3
  38. package/dist/chunk-index-eg55mqqe.js +1 -0
  39. package/dist/{chunk-index-xgkd15n3.js → chunk-index-gr6e0hse.js} +1 -1
  40. package/dist/chunk-index-gyary7np.js +13 -0
  41. package/dist/chunk-index-j214tczc.js +1 -0
  42. package/dist/chunk-index-jd6632ef.js +2 -0
  43. package/dist/chunk-index-jw7dtshr.js +2 -0
  44. package/dist/{chunk-index-wnjp6wv0.js → chunk-index-k7t14rwa.js} +2 -2
  45. package/dist/{chunk-index-c07p8k0x.js → chunk-index-kwcx62wb.js} +3 -3
  46. package/dist/{chunk-index-hby0ap91.js → chunk-index-m7fvw92f.js} +2 -2
  47. package/dist/chunk-index-mmg788a1.js +2 -0
  48. package/dist/chunk-index-mq0dacwr.js +2 -0
  49. package/dist/chunk-index-p53ak25v.js +2 -0
  50. package/dist/{chunk-index-rhtvcbsq.js → chunk-index-pk1yxkv4.js} +2 -2
  51. package/dist/{chunk-index-2stynxw6.css → chunk-index-rbcz9jd4.css} +1 -1
  52. package/dist/{chunk-index-km40bhsz.js → chunk-index-rmxbdmn2.js} +2 -2
  53. package/dist/chunk-index-rnxj3z8k.js +2 -0
  54. package/dist/{chunk-index-hssjbr1k.js → chunk-index-snzzfhk3.js} +2 -2
  55. package/dist/chunk-index-v3bmr0nw.js +2 -0
  56. package/dist/{chunk-index-awx6nnta.js → chunk-index-vcyzwtk2.js} +1 -1
  57. package/dist/{chunk-index-nqfkwkfp.js → chunk-index-w2j3njb6.js} +2 -2
  58. package/dist/{chunk-index-e5sd30xy.js → chunk-index-w31c4yjv.js} +2 -2
  59. package/dist/chunk-index-wrm8w165.js +2 -0
  60. package/dist/chunk-index-xh0mg9cw.js +2 -0
  61. package/dist/chunk-index-ybtvvx85.js +2 -0
  62. package/dist/{chunk-index-dhpt8qr9.js → chunk-index-yzsgsvnp.js} +2 -2
  63. package/dist/{chunk-index-66nskrdj.js → chunk-index-z9x4cnzf.js} +2 -2
  64. package/dist/chunk-index-zrvg23jd.js +2 -0
  65. package/dist/chunk-index-zynq0b0w.js +50 -0
  66. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  67. package/dist/{chunk-lang-dockerfile-hrav55t7.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  68. package/dist/{chunk-lang-go-x9ahv6e5.js → chunk-lang-go-wjwgay4v.js} +2 -2
  69. package/dist/chunk-lang-html-07d47es4.js +1 -0
  70. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  71. package/dist/{chunk-lang-json-hsemyd0r.js → chunk-lang-json-7gd89mvs.js} +1 -1
  72. package/dist/{chunk-lang-markdown-zkwb4c9n.js → chunk-lang-markdown-b6qbe8bt.js} +5 -5
  73. package/dist/{chunk-lang-python-tewx7006.js → chunk-lang-python-3v56y31b.js} +2 -2
  74. package/dist/{chunk-lang-rust-0ydx84s1.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  75. package/dist/{chunk-lang-shell-gq1tmdz4.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  76. package/dist/{chunk-lang-sql-xt1qyycg.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  77. package/dist/{chunk-lang-toml-0hamc5fc.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  78. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  79. package/dist/{chunk-lang-xml-wf6h5pt2.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  80. package/dist/{chunk-lang-yaml-7vk521h1.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  81. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  82. package/dist/{chunk-menu-items-1n9qargb.js → chunk-menu-items-1epy3h0t.js} +2 -2
  83. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  84. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  85. package/dist/{chunk-notes-t15bh04m.js → chunk-notes-yb8f057c.js} +1 -1
  86. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  87. package/dist/chunk-notices-x07da5bb.js +1 -0
  88. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  89. package/dist/{chunk-overview-km5vq375.js → chunk-overview-avc2d30t.js} +1 -1
  90. package/dist/{chunk-palette-a8txdgd0.js → chunk-palette-9nhp95bz.js} +1 -1
  91. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  92. package/dist/chunk-profile-6qemf3m7.js +1 -0
  93. package/dist/{chunk-prompt-history-wm9dsy6s.js → chunk-prompt-history-7enzp2p3.js} +1 -1
  94. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  95. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  96. package/dist/{chunk-room-menu-2tm2xtfx.js → chunk-room-menu-rr6nzap1.js} +1 -1
  97. package/dist/{chunk-rooms-menu-button-wxnsxjwc.js → chunk-rooms-menu-button-zbtwj47d.js} +1 -1
  98. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  99. package/dist/{chunk-seat-menu-d3npdtx1.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  100. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  101. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  102. package/dist/{chunk-skills-shkdwes8.js → chunk-skills-tnr1h16h.js} +1 -1
  103. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  104. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  105. package/dist/chunk-table-r12cja6p.js +1 -0
  106. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  107. package/dist/{chunk-table2d-n26b7gd0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  108. package/dist/{chunk-table3d-8aysyf56.js → chunk-table3d-4x2n2b00.js} +20 -20
  109. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  110. package/dist/chunk-transcript-n2s6dher.js +1 -0
  111. package/dist/chunk-where-f3scdydc.js +1 -0
  112. package/dist/chunk-workspace-xs023bxm.js +1 -0
  113. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  114. package/dist/index.html +1 -1
  115. package/dist/sw.js +1 -1
  116. package/package.json +2 -2
  117. package/src/api-types.ts +58 -3
  118. package/src/app/app.ts +5 -0
  119. package/src/app/room-log.ts +16 -4
  120. package/src/app/rooms.ts +39 -0
  121. package/src/app/routes.ts +44 -2
  122. package/src/app/socket.ts +45 -9
  123. package/src/app/threads.ts +31 -2
  124. package/src/auth/authenticator.ts +10 -4
  125. package/src/auth/login-page.ts +4 -2
  126. package/src/auth/oidc-pages.ts +2 -0
  127. package/src/auth/oidc.ts +39 -1
  128. package/src/authz.ts +2 -1
  129. package/src/console-server.ts +32 -6
  130. package/src/host/cards.ts +18 -5
  131. package/src/host/context.ts +72 -25
  132. package/src/host/forms.ts +3 -0
  133. package/src/host/frames.ts +8 -1
  134. package/src/host/host-app.ts +6 -4
  135. package/src/host/host-routes.ts +36 -3
  136. package/src/host/room-views.ts +6 -6
  137. package/src/host/rooms-service.ts +16 -1
  138. package/src/host/views.ts +3 -0
  139. package/src/i18n/zh-tw.ts +9 -2
  140. package/src/index.ts +15 -1
  141. package/src/options.ts +36 -0
  142. package/src/page-presentation.ts +47 -2
  143. package/src/pwa.ts +32 -4
  144. package/src/web-app.ts +47 -3
  145. package/src/web-plugin.ts +20 -9
  146. package/dist/chunk-account-hw49gd0d.js +0 -1
  147. package/dist/chunk-arranged-rooms-q5v3dm1r.js +0 -1
  148. package/dist/chunk-character-creator-5dxcvd16.js +0 -1
  149. package/dist/chunk-codeeditor-pyt09fwg.js +0 -2
  150. package/dist/chunk-confirm-521yqmta.js +0 -1
  151. package/dist/chunk-folder-list-b97xk12b.js +0 -1
  152. package/dist/chunk-folds-b8q7eags.js +0 -1
  153. package/dist/chunk-group-scene-dx6wt19m.js +0 -1
  154. package/dist/chunk-group-table-e4jr8r3a.js +0 -1
  155. package/dist/chunk-host-state-g7e7c0k5.js +0 -1
  156. package/dist/chunk-host-ui-x7k6n07v.js +0 -939
  157. package/dist/chunk-index-2qd81wbx.js +0 -2
  158. package/dist/chunk-index-459drsft.js +0 -2
  159. package/dist/chunk-index-4mfj596y.js +0 -2
  160. package/dist/chunk-index-bbsytycb.js +0 -2
  161. package/dist/chunk-index-cq1e9xdz.js +0 -2
  162. package/dist/chunk-index-g351921r.js +0 -1
  163. package/dist/chunk-index-k2qq1gdj.js +0 -2
  164. package/dist/chunk-index-ke9ynm0n.js +0 -2
  165. package/dist/chunk-index-kpscvg85.js +0 -2
  166. package/dist/chunk-index-m0d100mg.js +0 -2
  167. package/dist/chunk-index-mb72snk9.js +0 -2
  168. package/dist/chunk-index-mjqqver5.js +0 -4
  169. package/dist/chunk-index-nwqk447w.js +0 -2
  170. package/dist/chunk-index-pcfhpm8v.js +0 -302
  171. package/dist/chunk-index-q8h8xzbm.js +0 -2
  172. package/dist/chunk-index-r5vszgfs.js +0 -2
  173. package/dist/chunk-index-rjv7vrwd.js +0 -13
  174. package/dist/chunk-index-v23m38p6.js +0 -50
  175. package/dist/chunk-index-va0g7d88.js +0 -1
  176. package/dist/chunk-index-z3k18atc.js +0 -2
  177. package/dist/chunk-lang-css-6bepqpnx.js +0 -1
  178. package/dist/chunk-lang-html-8jc72zt4.js +0 -1
  179. package/dist/chunk-lang-javascript-2p2qtbnf.js +0 -1
  180. package/dist/chunk-lang-typescript-82xf7z2e.js +0 -1
  181. package/dist/chunk-layout-actions-zh7bezk7.js +0 -1
  182. package/dist/chunk-notice-bell-w3y0h0sr.js +0 -1
  183. package/dist/chunk-notices-fa3g7qze.js +0 -1
  184. package/dist/chunk-onboarding-34v3nr17.js +0 -1
  185. package/dist/chunk-placement-items-wgaxsbvs.js +0 -1
  186. package/dist/chunk-profile-ypxdbsk1.js +0 -1
  187. package/dist/chunk-prompts-7mhhq7zs.js +0 -1
  188. package/dist/chunk-room-confirms-t11a1gsw.js +0 -1
  189. package/dist/chunk-schedules-2ft6xxyw.js +0 -1
  190. package/dist/chunk-sheet-hosts-vngvp7wv.js +0 -1
  191. package/dist/chunk-skill-editor-65n2hsq4.js +0 -8
  192. package/dist/chunk-switcher-list-g6hhmr1t.js +0 -1
  193. package/dist/chunk-table-1b6bsq2y.js +0 -1
  194. package/dist/chunk-table-seats-vy1qhdks.js +0 -1
  195. package/dist/chunk-transcript-355faceq.js +0 -1
  196. package/dist/chunk-where-j5qdt8a4.js +0 -1
  197. package/dist/chunk-workspace-vrbs6zx1.js +0 -1
  198. package/dist/chunk-zh-tw-zeg600bk.js +0 -1
package/src/host/forms.ts CHANGED
@@ -125,6 +125,7 @@ export function checkCard(card: {
125
125
  state?: string | undefined;
126
126
  links?: readonly { label: string; url: string }[] | undefined;
127
127
  buttons?: readonly HostAction[] | undefined;
128
+ keep?: boolean | undefined;
128
129
  }): void {
129
130
  if (typeof card.title !== "string" || card.title.length > LIMITS.title)
130
131
  fail(`a card's title is at most ${LIMITS.title} characters`);
@@ -134,6 +135,8 @@ export function checkCard(card: {
134
135
  fail(`a card's tone is note, ok, warn or risk`);
135
136
  if (card.state !== undefined && !STATES.has(card.state))
136
137
  fail(`a card's state is open, working, done, superseded or expired`);
138
+ if (card.keep !== undefined && typeof card.keep !== "boolean")
139
+ fail("a card's keep is true or false");
137
140
  const links = card.links ?? [];
138
141
  if (links.length > LIMITS.links)
139
142
  fail(`a card takes at most ${LIMITS.links} links`);
@@ -60,7 +60,7 @@ export async function actInRoom(
60
60
  if (press.card !== undefined) {
61
61
  const card = await ctx.cards.get(room, press.card);
62
62
  if (!card) return { refused: "unknown" };
63
- offered = buttonsFor(viewer, found, access, card);
63
+ offered = await buttonsFor(viewer, found, access, card);
64
64
  } else {
65
65
  const header = await found.kind
66
66
  .header?.(viewer, found.id)
@@ -146,6 +146,13 @@ export function installFrames(ctx: HostContext): void {
146
146
  connection,
147
147
  audience: { room: frame.room },
148
148
  });
149
+ // So is a turn already running: a seat at the round table shows it from the first frame, not after the next change.
150
+ const activity = ctx.app.activityOf(asRoom(frame.room));
151
+ if (activity.typing)
152
+ ctx.sockets.deliver(
153
+ { type: "activity", room: frame.room, ...activity },
154
+ { connection, audience: { room: frame.room } },
155
+ );
149
156
  },
150
157
  );
151
158
 
@@ -19,7 +19,7 @@ import type { Uploads } from "../app/uploads.ts";
19
19
  import type { Viewer, WebAppRegistry } from "../web-app.ts";
20
20
  import { HostAccess } from "./access.ts";
21
21
  import type { HostCardLog } from "./cards.ts";
22
- import { hostChannelContext } from "./context.ts";
22
+ import { type HostRoomContext, hostRoomContext } from "./context.ts";
23
23
  import { installFrames } from "./frames.ts";
24
24
  import type { HostContext } from "./host-context.ts";
25
25
  import { hostArea, pagesArea } from "./host-routes.ts";
@@ -66,7 +66,7 @@ export class HostApp {
66
66
  readonly rooms: HostRoomsService;
67
67
  readonly pages: HostPagesService;
68
68
  readonly #context: HostContext;
69
- readonly #channelContext: ReturnType<typeof hostChannelContext>;
69
+ readonly #roomContext: HostRoomContext;
70
70
 
71
71
  constructor(options: HostAppOptions) {
72
72
  const now = options.now ?? Date.now;
@@ -102,6 +102,8 @@ export class HostApp {
102
102
  maxFileBytes: options.maxFileBytes,
103
103
  roomUrl: options.roomUrl,
104
104
  listed,
105
+ context: (room, contextOptions) =>
106
+ this.#roomContext.peek(room, contextOptions),
105
107
  now,
106
108
  logger: options.logger,
107
109
  });
@@ -126,7 +128,7 @@ export class HostApp {
126
128
  now,
127
129
  logger: options.logger,
128
130
  };
129
- this.#channelContext = hostChannelContext(this.#context);
131
+ this.#roomContext = hostRoomContext(this.#context);
130
132
  }
131
133
 
132
134
  /** Plugs the pieces into the registry: who reads, the frames, the areas, and the services a host reaches. */
@@ -153,7 +155,7 @@ export class HostApp {
153
155
  message: InboundMessage,
154
156
  options?: ChannelContextOptions,
155
157
  ): Promise<ChannelContext | undefined> {
156
- return this.#channelContext(message, options);
158
+ return this.#roomContext.read(message, options);
157
159
  }
158
160
 
159
161
  /** Whether a stored file bound to a room or a page may be served to this viewer now. */
@@ -1,4 +1,4 @@
1
- import type { HostCardView, RoomMessage } from "../api-types.ts";
1
+ import type { HostCardView, RoomMessage, SendWording } from "../api-types.ts";
2
2
  import { bad, bodyOf, HttpError } from "../app/api-http.ts";
3
3
  import { PAGE_LIMIT } from "../app/room-log.ts";
4
4
  import { json } from "../http.ts";
@@ -244,14 +244,15 @@ async function open(
244
244
  ctx.log.page(room, undefined, OPEN_LIMIT),
245
245
  ctx.cards.list(room),
246
246
  ]);
247
- const cards: HostCardView[] = stored.map((card) =>
248
- cardView(viewer, found, access, card),
247
+ const cards: HostCardView[] = await Promise.all(
248
+ stored.map((card) => cardView(viewer, found, access, card)),
249
249
  );
250
250
  return {
251
251
  room,
252
252
  kind: found.kind.kind,
253
253
  id: found.id,
254
254
  access: { send: [...access.send], act: access.act },
255
+ ...wordingOf(found.kind.sendWording),
255
256
  header,
256
257
  messages: withExpiredFiles(page.messages, ctx.files),
257
258
  more: page.more,
@@ -261,6 +262,38 @@ async function open(
261
262
  };
262
263
  }
263
264
 
265
+ /** The longest label and placeholder a kind may give, in characters. */
266
+ const LABEL_CHARS = 40;
267
+ const PLACEHOLDER_CHARS = 100;
268
+
269
+ const trimmed = (value: unknown, chars: number): string | undefined =>
270
+ typeof value === "string"
271
+ ? Array.from(value.trim()).slice(0, chars).join("") || undefined
272
+ : undefined;
273
+
274
+ /** The kind's wording of the two ways to write as the page may show it: text only, cut short; nothing when it gave none. */
275
+ function wordingOf(wording: SendWording | undefined): {
276
+ wording?: SendWording;
277
+ } {
278
+ const note = trimmed(wording?.note?.placeholder, PLACEHOLDER_CHARS);
279
+ const label = trimmed(wording?.turn?.label, LABEL_CHARS);
280
+ const turn = trimmed(wording?.turn?.placeholder, PLACEHOLDER_CHARS);
281
+ if (!note && !label && !turn) return {};
282
+ return {
283
+ wording: {
284
+ ...(note ? { note: { placeholder: note } } : {}),
285
+ ...(label || turn
286
+ ? {
287
+ turn: {
288
+ ...(label ? { label } : {}),
289
+ ...(turn ? { placeholder: turn } : {}),
290
+ },
291
+ }
292
+ : {}),
293
+ },
294
+ };
295
+ }
296
+
264
297
  async function older(
265
298
  ctx: HostContext,
266
299
  viewer: Viewer,
@@ -77,27 +77,27 @@ export interface FoundRoom {
77
77
  * The buttons of a host card this viewer may press: none once the card is settled, none without
78
78
  * `access.act`, and otherwise the card's own, narrowed by the kind's `buttons` when it has one.
79
79
  */
80
- export function buttonsFor(
80
+ export async function buttonsFor(
81
81
  viewer: Viewer,
82
82
  found: FoundRoom,
83
83
  access: RoomAccess,
84
84
  card: StoredHostCard,
85
- ): NonNullable<StoredHostCard["buttons"]> {
85
+ ): Promise<NonNullable<StoredHostCard["buttons"]>> {
86
86
  const all = card.buttons ?? [];
87
87
  if (!access.act || !pressable(card) || all.length === 0) return [];
88
88
  if (!found.kind.buttons) return all;
89
- const allowed = new Set(found.kind.buttons(viewer, found.id, card));
89
+ const allowed = new Set(await found.kind.buttons(viewer, found.id, card));
90
90
  return all.filter((button) => allowed.has(button.id));
91
91
  }
92
92
 
93
93
  /** A host card as one viewer sees it. */
94
- export function cardView(
94
+ export async function cardView(
95
95
  viewer: Viewer,
96
96
  found: FoundRoom,
97
97
  access: RoomAccess,
98
98
  card: StoredHostCard,
99
- ): HostCardView {
100
- const buttons = buttonsFor(viewer, found, access, card);
99
+ ): Promise<HostCardView> {
100
+ const buttons = await buttonsFor(viewer, found, access, card);
101
101
  return {
102
102
  id: card.id,
103
103
  room: card.room,
@@ -1,5 +1,6 @@
1
1
  import type { ChannelKey, Logger } from "pi-roundtable";
2
2
  import { PluginError } from "pi-roundtable";
3
+ import type { ChannelContext } from "pi-roundtable/discord";
3
4
  import type { ServerFrame } from "../api-types.ts";
4
5
  import { MAX_MESSAGE_CHARS } from "../api-types.ts";
5
6
  import { type FileDir, isFileId, safeName } from "../app/files.ts";
@@ -15,6 +16,7 @@ import type {
15
16
  } from "../web-app.ts";
16
17
  import type { HostAccess } from "./access.ts";
17
18
  import type { HostCardLog } from "./cards.ts";
19
+ import type { HostRoomContextOptions } from "./context.ts";
18
20
  import { checkCard } from "./forms.ts";
19
21
  import { cardView, checkPicture, type FoundRoom } from "./room-views.ts";
20
22
 
@@ -37,6 +39,11 @@ export interface HostRoomsOptions {
37
39
  roomUrl(room: ChannelKey, message?: number): string;
38
40
  /** Which kinds a person's last list showed, so the list's changes reach only them. */
39
41
  listed: ListedKinds;
42
+ /** A room's recent messages as a channel-context block, without marking them handed over. */
43
+ context(
44
+ room: string,
45
+ options?: HostRoomContextOptions,
46
+ ): Promise<ChannelContext | undefined>;
40
47
  now?: () => number;
41
48
  logger: Logger;
42
49
  }
@@ -272,6 +279,14 @@ export class HostRoomsService implements HostRooms {
272
279
  return this.#options.log.byReference(room, reference);
273
280
  }
274
281
 
282
+ async context(
283
+ room: ChannelKey,
284
+ options?: HostRoomContextOptions,
285
+ ): Promise<ChannelContext | undefined> {
286
+ this.#found(room);
287
+ return this.#options.context(room, options);
288
+ }
289
+
275
290
  // ── Cards ──
276
291
 
277
292
  async card(room: ChannelKey, card: HostCard): Promise<{ id: string }> {
@@ -313,7 +328,7 @@ export class HostRoomsService implements HostRooms {
313
328
  {
314
329
  type: "host_card",
315
330
  room,
316
- card: cardView(viewer, found, access, stored),
331
+ card: await cardView(viewer, found, access, stored),
317
332
  },
318
333
  { connection, audience: { room } },
319
334
  );
package/src/host/views.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  PictureRef,
9
9
  ReadMarkView,
10
10
  RoomMessage,
11
+ SendWording,
11
12
  } from "../api-types.ts";
12
13
 
13
14
  /** One room of a kind, as the list shows it. */
@@ -47,6 +48,8 @@ export interface HostRoomView {
47
48
  id: string;
48
49
  /** What this viewer may do in the room. */
49
50
  access: { send: ("note" | "turn")[]; act: boolean };
51
+ /** The kind's own wording of the two ways to write, where it gave any. */
52
+ wording?: SendWording;
50
53
  header: HostHeaderView;
51
54
  /** The newest messages, oldest first. */
52
55
  messages: RoomMessage[];
package/src/i18n/zh-tw.ts CHANGED
@@ -288,6 +288,7 @@ export const ZH_TW: Readonly<Record<string, string>> = {
288
288
  "Leave the table": "離席",
289
289
  "{name} left the table": "{name}已離席",
290
290
  "The table is empty": "圓桌空了",
291
+ "There are no rooms to seat yet.": "還沒有可以入座的聊天室。",
291
292
  "Every agent has left the table. Choose who sits down.":
292
293
  "所有夥伴都離席了。選擇誰要上桌。",
293
294
  "An agent who leaves the table is muted: its room stops pushing to you. A card that waits for you is always pushed.":
@@ -580,6 +581,8 @@ export const ZH_TW: Readonly<Record<string, string>> = {
580
581
  "{count} cards waiting": "{count} 張卡片等待中",
581
582
  "{count} finished thread": "{count} 個已結束的討論串",
582
583
  "{count} finished threads": "{count} 個已結束的討論串",
584
+ "Clear all": "清除全部",
585
+ "The thread is still open.": "這個討論串還在進行中。",
583
586
 
584
587
  // 排程
585
588
  "{days} at {time}": "每{days} {time}",
@@ -1050,8 +1053,12 @@ export const ZH_TW: Readonly<Record<string, string>> = {
1050
1053
  "This page is not available.": "這個頁面無法使用。",
1051
1054
  "You can read this room but not write in it.":
1052
1055
  "你可以閱讀這個聊天室,但不能在其中發言。",
1053
- "Send as": "傳送方式",
1054
- "Ask the agent": "請夥伴處理",
1056
+ "@AI": "@AI",
1057
+ "The message starts with @AI, so the agent answers.":
1058
+ "訊息開頭是 @AI,所以 AI 會回覆。",
1059
+ "Write an internal note…": "寫一則內部備註…",
1060
+ "Ask AI…": "問 AI…",
1061
+ "Internal note": "內部備註",
1055
1062
  "Earlier cards ({count})": "先前的卡片({count})",
1056
1063
  Replaced: "已被取代",
1057
1064
  Image: "圖片",
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  ConversationView,
13
13
  DashboardView,
14
14
  GroupView,
15
+ HostIconName,
15
16
  NoteInput,
16
17
  NoteKind,
17
18
  NoteView,
@@ -26,7 +27,7 @@ export type {
26
27
  TranscriptRole,
27
28
  TranscriptView,
28
29
  } from "./api-types.ts";
29
- export { PANES } from "./api-types.ts";
30
+ export { HOST_ICONS, PANES } from "./api-types.ts";
30
31
  export type { CloudflareAccessOptions } from "./cloudflare-access.ts";
31
32
  export {
32
33
  cloudflareAccess,
@@ -39,4 +40,17 @@ export type { WebAuthOptions, WebConsoleOptions } from "./options.ts";
39
40
  export { DEFAULT_RELAY_NOTE } from "./options.ts";
40
41
  export type { RequestVerifier, Verdict } from "./verifier.ts";
41
42
  export { admit, admitAs, refuse } from "./verifier.ts";
43
+ export type {
44
+ ActionResult,
45
+ HostCard,
46
+ HostPage,
47
+ HostRoomKind,
48
+ LinkProver,
49
+ Notices,
50
+ OnboardingStep,
51
+ OpenTarget,
52
+ Viewer,
53
+ WebApp,
54
+ } from "./web-app.ts";
55
+ export { WEB_APP } from "./web-app.ts";
42
56
  export { webConsole } from "./web-plugin.ts";
package/src/options.ts CHANGED
@@ -116,6 +116,14 @@ export interface OidcOptions {
116
116
  reauthHours?: number;
117
117
  /** The id_token claims to keep with the session (replaced at each sign-in and re-verify, never logged); default none. */
118
118
  storeClaims?: readonly string[];
119
+ /**
120
+ * Called with the person's principal id when a request or an open socket proves they hold a
121
+ * live session, at most once per session every five minutes. For a host that counts who is
122
+ * present, such as one that keeps a linked chat account only while its web person is active: a
123
+ * person who only has the app open, calling nothing of the host's, still counts. It is not
124
+ * awaited, and an error it throws is logged and never fails the request.
125
+ */
126
+ onSessionVerified?: (principalId: string) => void | Promise<void>;
119
127
  }
120
128
 
121
129
  /** `auth` checked and filled in. */
@@ -145,6 +153,9 @@ export interface ResolvedOidc {
145
153
  endSession: boolean;
146
154
  reauthHours: number | undefined;
147
155
  storeClaims: readonly string[];
156
+ onSessionVerified:
157
+ | ((principalId: string) => void | Promise<void>)
158
+ | undefined;
148
159
  }
149
160
 
150
161
  /** The panes each tier is served, when the list differs by tier. */
@@ -331,6 +342,11 @@ export interface WebConsoleOptions {
331
342
  uploads?: { keepDays?: number };
332
343
  /** The page's title and heading; default `Roundtable`. */
333
344
  title?: string;
345
+ /**
346
+ * The page's description for search results and link previews (`description` and
347
+ * `og:description`); default a line derived from the title.
348
+ */
349
+ description?: string;
334
350
  /** Where the install icons come from; default the ones this package ships. */
335
351
  icons?: { dir: string };
336
352
  /** How the page looks: `modern` (default) or `camelot`. The page's owner can switch it in the app. */
@@ -397,6 +413,7 @@ export interface ResolvedOptions {
397
413
  modes: boolean;
398
414
  uploadKeepDays: number;
399
415
  title: string;
416
+ description: string | undefined;
400
417
  iconsDir: string | undefined;
401
418
  look: "modern" | "camelot";
402
419
  relayNotes: readonly string[];
@@ -546,6 +563,11 @@ function checkPanes(
546
563
  `${label} names the ${pane} pane, which the owners' panes do not have; the pane's API is served for the owners' list`,
547
564
  );
548
565
  }
566
+ // A member's table seats the host rooms they may list, which the rooms pane brings with its socket.
567
+ if (list.includes("table") && !list.includes("rooms"))
568
+ fail(
569
+ `${label} names the table pane without the rooms pane, whose host rooms it seats`,
570
+ );
549
571
  }
550
572
  return { owner, admin, member, given: true };
551
573
  }
@@ -704,6 +726,7 @@ function checkOidc(auth: OidcOptions): ResolvedOidc {
704
726
  "endSession",
705
727
  "reauthHours",
706
728
  "storeClaims",
729
+ "onSessionVerified",
707
730
  ]);
708
731
  for (const key of Object.keys(auth))
709
732
  if (!known.has(key)) fail(`auth.${key} is not an oidc option`);
@@ -778,6 +801,11 @@ function checkOidc(auth: OidcOptions): ResolvedOidc {
778
801
  !storeClaims.every((name) => typeof name === "string" && name.trim())
779
802
  )
780
803
  fail("auth.storeClaims must be a list of claim names");
804
+ if (
805
+ auth.onSessionVerified !== undefined &&
806
+ typeof auth.onSessionVerified !== "function"
807
+ )
808
+ fail("auth.onSessionVerified must be a function");
781
809
  return {
782
810
  mode: "oidc",
783
811
  issuer,
@@ -798,6 +826,7 @@ function checkOidc(auth: OidcOptions): ResolvedOidc {
798
826
  endSession: auth.endSession === true,
799
827
  reauthHours: auth.reauthHours,
800
828
  storeClaims: [...storeClaims],
829
+ onSessionVerified: auth.onSessionVerified,
801
830
  };
802
831
  }
803
832
 
@@ -1028,6 +1057,12 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
1028
1057
  fail("appearance.look must be modern or camelot");
1029
1058
  const title = options.title?.trim() ?? "Roundtable";
1030
1059
  if (!title) fail("title is empty");
1060
+ const description = options.description?.trim();
1061
+ if (
1062
+ options.description !== undefined &&
1063
+ (typeof options.description !== "string" || !description)
1064
+ )
1065
+ fail("description is empty. Leave it out for the default");
1031
1066
  return {
1032
1067
  auth,
1033
1068
  origin,
@@ -1050,6 +1085,7 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
1050
1085
  modes: options.modes !== false,
1051
1086
  uploadKeepDays: checkUploads(options),
1052
1087
  title,
1088
+ description,
1053
1089
  iconsDir: options.icons?.dir,
1054
1090
  look,
1055
1091
  relayNotes: options.relayNotes ?? [DEFAULT_RELAY_NOTE],
@@ -2,7 +2,7 @@ import type { ConsolePresentation } from "./features.ts";
2
2
  import { escapeHtml } from "./http.ts";
3
3
  import { PAGE_COLORS } from "./page-colors.ts";
4
4
 
5
- /** The tags that make the page installable; the URLs resolve against the page, so the mount does not matter. */
5
+ /** The tags that make the page installable; their URLs resolve against the base, so they follow it. */
6
6
  const pwaTags = (look: "modern" | "camelot"): string =>
7
7
  '<link rel="manifest" href="./manifest.webmanifest" crossorigin="use-credentials">' +
8
8
  `<meta name="theme-color" content="${PAGE_COLORS[look].light}" media="(prefers-color-scheme: light)">` +
@@ -10,21 +10,66 @@ const pwaTags = (look: "modern" | "camelot"): string =>
10
10
  '<link rel="apple-touch-icon" href="./icons/apple-180.png">' +
11
11
  '<meta name="apple-mobile-web-app-capable" content="yes">';
12
12
 
13
+ /**
14
+ * The icon links of a page served under `mount`: the round-table mark as an SVG when the icon
15
+ * directory has one, and the 192 px PNG every icon directory has (the manifest names it). Without
16
+ * them a browser asks the site root for `/favicon.ico`, which is not this app's.
17
+ */
18
+ export const iconLinks = (mount: string, svg: boolean): string => {
19
+ const base = escapeHtml(mount);
20
+ const mark = svg
21
+ ? `<link rel="icon" href="${base}/icons/mark.svg" type="image/svg+xml" sizes="any">`
22
+ : "";
23
+ return `${mark}<link rel="icon" href="${base}/icons/192.png" type="image/png">`;
24
+ };
25
+
26
+ /** What a page says about itself to a tab, a search result and a link preview. */
27
+ export interface PageMeta {
28
+ title: string;
29
+ description: string;
30
+ /** The console's origin, so `og:image` is an absolute URL. */
31
+ origin: string;
32
+ /** The path the console is served under. */
33
+ mount: string;
34
+ /** Whether the icon directory has `mark.svg`. */
35
+ svg: boolean;
36
+ }
37
+
38
+ /** The text a host that sets no `description` gets. */
39
+ export const defaultDescription = (title: string): string =>
40
+ `Chat with your agent team in ${title}.`;
41
+
42
+ const metaTags = (meta: PageMeta): string =>
43
+ `<meta name="description" content="${escapeHtml(meta.description)}">` +
44
+ `<meta property="og:title" content="${escapeHtml(meta.title)}">` +
45
+ `<meta property="og:description" content="${escapeHtml(meta.description)}">` +
46
+ `<meta property="og:image" content="${escapeHtml(`${meta.origin}${meta.mount}/icons/512.png`)}">`;
47
+
13
48
  /** Authenticated, inert bootstrap data localizes even a failed first API request without inline scripts. */
14
49
  export function presentPage(
15
50
  body: Uint8Array,
16
51
  mount: string | undefined,
17
52
  presentation: ConsolePresentation | undefined,
18
53
  look: "modern" | "camelot" = "modern",
54
+ meta?: PageMeta,
19
55
  ): Uint8Array<ArrayBuffer> | string {
20
56
  // Not in web/index.html: the bundler would try to resolve and rewrite these URLs.
21
- let head = `<head>${pwaTags(look)}`;
57
+ // The base comes first: under path routing a relative link before it resolves against a deep URL.
58
+ let head = "<head>";
22
59
  if (mount) head += `<base href="${escapeHtml(`${mount}/`)}">`;
60
+ head += pwaTags(look);
61
+ if (meta) head += iconLinks(meta.mount, meta.svg) + metaTags(meta);
23
62
  if (presentation)
24
63
  head += `<meta name="roundtable-console-presentation" content="${escapeHtml(JSON.stringify(presentation))}">`;
25
64
  // After the base, so it resolves under the mount; a script of the page's own origin, which its policy allows.
26
65
  head += '<script src="./theme-init.js"></script>';
27
66
  let html = new TextDecoder().decode(body).replace("<head>", () => head);
67
+ // web/index.html ships a placeholder: the page is not rendered until the bundle sets the title.
68
+ if (meta)
69
+ html = html.replace(
70
+ /<title>[^<]*<\/title>/,
71
+ () => `<title>${escapeHtml(meta.title)}</title>`,
72
+ );
28
73
  if (presentation?.locale)
29
74
  html = html.replace(
30
75
  /(<html[^>]*lang=")[^"]*(")/,
package/src/pwa.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AssetBundle } from "./assets.ts";
@@ -34,7 +34,27 @@ export const DEFAULT_ICONS_DIR = fileURLToPath(
34
34
  new URL("../assets/icons/", import.meta.url),
35
35
  );
36
36
 
37
- const ICON_FILE = /^[A-Za-z0-9-]+\.png$/;
37
+ const ICON_FILE = /^[A-Za-z0-9-]+\.(png|svg)$/;
38
+
39
+ /** The icon files the page, the manifest and the sign-in pages link. */
40
+ const PUBLIC_ICONS: ReadonlySet<string> = new Set([
41
+ "192.png",
42
+ "512.png",
43
+ "maskable-512.png",
44
+ "apple-180.png",
45
+ "badge-96.png",
46
+ "mark.svg",
47
+ "mark-maskable.svg",
48
+ ]);
49
+
50
+ /** The manifest and the linked icons answer without a session: a sign-in page and a link preview have none. Any other file of a host's icon directory stays behind it. */
51
+ export const isPublicStatic = (rest: string): boolean =>
52
+ rest === "manifest.webmanifest" ||
53
+ (rest.startsWith("icons/") && PUBLIC_ICONS.has(rest.slice("icons/".length)));
54
+
55
+ /** Whether the icon directory has the round-table mark as an SVG, which a tab shows crisper than a PNG. */
56
+ export const hasMarkSvg = (iconsDir: string | undefined): boolean =>
57
+ existsSync(join(iconsDir ?? DEFAULT_ICONS_DIR, "mark.svg"));
38
58
 
39
59
  export interface PwaOptions {
40
60
  mount: string;
@@ -116,10 +136,18 @@ export function servePwa(
116
136
  const body = readFileSync(
117
137
  join(options.iconsDir ?? DEFAULT_ICONS_DIR, name),
118
138
  );
139
+ const svg = name.endsWith(".svg");
119
140
  return new Response(new Uint8Array(body), {
120
141
  headers: {
121
- "content-type": "image/png",
122
- "cache-control": "private, max-age=86400",
142
+ "content-type": svg ? "image/svg+xml" : "image/png",
143
+ "cache-control": "public, max-age=86400",
144
+ // An SVG opened as a page must not run anything of its own.
145
+ ...(svg
146
+ ? {
147
+ "content-security-policy":
148
+ "default-src 'none'; style-src 'unsafe-inline'",
149
+ }
150
+ : {}),
123
151
  },
124
152
  });
125
153
  } catch {
package/src/web-app.ts CHANGED
@@ -1,16 +1,24 @@
1
1
  import type { ChannelKey, ServiceKey, Tier } from "pi-roundtable";
2
2
  import { PluginError, serviceKey } from "pi-roundtable";
3
+ import type { ChannelContext } from "pi-roundtable/discord";
3
4
  import type {
4
5
  FormFieldView,
5
6
  FormSpecView,
6
7
  HostActionView,
7
8
  HostAuthorKind,
9
+ HostIconName,
8
10
  PictureRef,
9
11
  RoomFile,
12
+ SendWording,
10
13
  } from "./api-types.ts";
14
+ import { HOST_ICONS } from "./api-types.ts";
11
15
  import type { Visitor } from "./visitors.ts";
12
16
 
13
- export type { HostAuthorKind, PictureRef };
17
+ export type { HostAuthorKind, HostIconName, PictureRef, SendWording };
18
+
19
+ /** A host's icon name as the page may show it: one of `HOST_ICONS`, else none (the default shows). */
20
+ export const hostIcon = (name: unknown): HostIconName | undefined =>
21
+ HOST_ICONS.find((icon) => icon === name);
14
22
 
15
23
  /**
16
24
  * Who a host plugin's callbacks serve: the person behind a request or a socket frame, with the
@@ -104,6 +112,15 @@ export interface RoomHeader {
104
112
  export interface HostRoomKind {
105
113
  kind: string;
106
114
  label: string;
115
+ /** An icon of the console's own set, shown beside the kind's section; none = no icon. */
116
+ icon?: HostIconName;
117
+ /**
118
+ * The kind's own words for writing in its rooms. Where a viewer may send both a note and a turn,
119
+ * the room shows one toggle, "@AI" (off = a note for the room's people only, on =
120
+ * the agent answers): `turn.label` words the toggle, and each mode's `placeholder` is the box's
121
+ * hint while that mode is on. Without it the console's own words show.
122
+ */
123
+ sendWording?: SendWording;
107
124
  /** Rooms this viewer may open. Undefined = the kind is not shown to them. */
108
125
  list(
109
126
  viewer: Viewer,
@@ -129,7 +146,11 @@ export interface HostRoomKind {
129
146
  input: FormValues,
130
147
  ): Promise<ActionResult>;
131
148
  /** The buttons of a HOST card this viewer may press; default all when `access.act`. Never applies to held-call or question cards. */
132
- buttons?(viewer: Viewer, id: string, card: StoredHostCard): readonly string[];
149
+ buttons?(
150
+ viewer: Viewer,
151
+ id: string,
152
+ card: StoredHostCard,
153
+ ): readonly string[] | Promise<readonly string[]>;
133
154
  /** How long the kind's messages, author names, host cards and files are kept. Unset = keep. */
134
155
  retention?: { textDays: number; fileDays: number };
135
156
  }
@@ -143,6 +164,12 @@ export interface HostCard {
143
164
  /** `https` or same-origin only. */
144
165
  links?: readonly { label: string; url: string }[];
145
166
  buttons?: readonly HostAction[];
167
+ /**
168
+ * Keeps the card past its kind's `retention.textDays`, for as long as the host passes `true`: a
169
+ * ticket's pinned card while the ticket is open. The sweep skips it; `updateCard` without `keep`
170
+ * (or with `false`) makes it an ordinary card again, counted from when it was first posted.
171
+ */
172
+ keep?: boolean;
146
173
  }
147
174
 
148
175
  /** A host card as it is stored. */
@@ -197,6 +224,17 @@ export interface HostRooms {
197
224
  /** Deletes the row and its files now. */
198
225
  remove(room: ChannelKey, id: number): Promise<void>;
199
226
  byReference(room: ChannelKey, reference: string): Promise<number | undefined>;
227
+ /**
228
+ * A room's recent messages as the block `CHANNEL_CONTEXT.read` gives a turn: what was said
229
+ * since the assistant last posted, at most `limit` (default 15, at most 100) newest messages,
230
+ * an outsider's words marked `external`, notices left out. For a host that starts a turn from
231
+ * a room's `send` hook and has no message that addressed the assistant. Unlike `read` it marks
232
+ * nothing handed over, so a retry gets the same; undefined when the room has nothing to give.
233
+ */
234
+ context(
235
+ room: ChannelKey,
236
+ options?: { limit?: number; messageChars?: number },
237
+ ): Promise<ChannelContext | undefined>;
200
238
  card(room: ChannelKey, card: HostCard): Promise<{ id: string }>;
201
239
  updateCard(room: ChannelKey, id: string, card: HostCard): Promise<void>;
202
240
  saveFile(
@@ -237,6 +275,8 @@ export interface HostPage {
237
275
  /** `[a-z0-9-]{1,40}`. */
238
276
  id: string;
239
277
  label: string;
278
+ /** An icon of the console's own set, shown in the navigation; none = a list. */
279
+ icon?: HostIconName;
240
280
  place: "main" | "settings" | "hidden";
241
281
  visible(viewer: Viewer): boolean | Promise<boolean>;
242
282
  view(
@@ -255,7 +295,11 @@ export interface HostPage {
255
295
  /** Person-addressed notices with state: the bell, the badge, push. */
256
296
  export interface Notices {
257
297
  raise(notice: {
258
- /** The dedupe key: raising it again updates it and pushes again. */
298
+ /**
299
+ * The dedupe key: raising it again updates it and pushes again. It is also the push tag
300
+ * and the acknowledgement key, so the people the notice is for can see it: put no private
301
+ * id in it.
302
+ */
259
303
  tag: string;
260
304
  /** Principal ids. */
261
305
  to: readonly string[];