@things-factory/ai-assistant 10.1.208 → 10.1.210

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 (167) hide show
  1. package/client/components/assistant-chat.ts +238 -9
  2. package/client/components/assistant-dock.ts +9 -4
  3. package/client/components/assistant-invite.ts +138 -0
  4. package/client/components/assistant-session-manager.ts +53 -12
  5. package/client/components/proposal-request.test.ts +10 -0
  6. package/client/components/roster-identity.test.ts +2 -12
  7. package/client/components/roster-identity.ts +0 -6
  8. package/client/utils/assistant-session-actions.ts +3 -1
  9. package/client/utils/assistant-session-admin.ts +9 -2
  10. package/client/utils/assistant-session-controller.ts +1 -1
  11. package/client/utils/assistant-typing.test.ts +33 -0
  12. package/client/utils/assistant-typing.ts +53 -0
  13. package/dist-client/components/assistant-chat.d.ts +28 -0
  14. package/dist-client/components/assistant-chat.js +259 -8
  15. package/dist-client/components/assistant-chat.js.map +1 -1
  16. package/dist-client/components/assistant-dock.js +12 -4
  17. package/dist-client/components/assistant-dock.js.map +1 -1
  18. package/dist-client/components/assistant-invite.d.ts +39 -0
  19. package/dist-client/components/assistant-invite.js +150 -0
  20. package/dist-client/components/assistant-invite.js.map +1 -0
  21. package/dist-client/components/assistant-session-manager.d.ts +5 -1
  22. package/dist-client/components/assistant-session-manager.js +40 -10
  23. package/dist-client/components/assistant-session-manager.js.map +1 -1
  24. package/dist-client/components/proposal-request.test.js +8 -0
  25. package/dist-client/components/proposal-request.test.js.map +1 -1
  26. package/dist-client/components/roster-identity.d.ts +0 -2
  27. package/dist-client/components/roster-identity.js +0 -7
  28. package/dist-client/components/roster-identity.js.map +1 -1
  29. package/dist-client/components/roster-identity.test.js +2 -13
  30. package/dist-client/components/roster-identity.test.js.map +1 -1
  31. package/dist-client/tsconfig.tsbuildinfo +1 -1
  32. package/dist-client/utils/assistant-session-actions.d.ts +1 -0
  33. package/dist-client/utils/assistant-session-actions.js +3 -1
  34. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  35. package/dist-client/utils/assistant-session-admin.d.ts +5 -2
  36. package/dist-client/utils/assistant-session-admin.js +3 -0
  37. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  38. package/dist-client/utils/assistant-session-controller.d.ts +1 -1
  39. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  40. package/dist-client/utils/assistant-typing.d.ts +20 -0
  41. package/dist-client/utils/assistant-typing.js +48 -0
  42. package/dist-client/utils/assistant-typing.js.map +1 -0
  43. package/dist-client/utils/assistant-typing.test.d.ts +1 -0
  44. package/dist-client/utils/assistant-typing.test.js +30 -0
  45. package/dist-client/utils/assistant-typing.test.js.map +1 -0
  46. package/dist-server/service/assistant-chat-resolver.js +5 -12
  47. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  48. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  49. package/dist-server/service/assistant-mode/index.js +2 -1
  50. package/dist-server/service/assistant-mode/index.js.map +1 -1
  51. package/dist-server/service/assistant-mode/mode-categories.js +9 -2
  52. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  53. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  54. package/dist-server/service/assistant-mode/screen-rules.js +10 -3
  55. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  56. package/dist-server/service/chat-message/chat-message-room.js +3 -1
  57. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  58. package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
  59. package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
  60. package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
  62. package/dist-server/service/chat-message/chat-typing.js +40 -0
  63. package/dist-server/service/chat-message/chat-typing.js.map +1 -0
  64. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  65. package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
  66. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  67. package/dist-server/service/chat-session/attachment-tools.js +2 -2
  68. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  69. package/dist-server/service/chat-session/chat-session.d.ts +2 -1
  70. package/dist-server/service/chat-session/chat-session.js +1 -1
  71. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  72. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
  73. package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
  74. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
  75. package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
  76. package/dist-server/service/chat-session/session-activity-publish.js +5 -2
  77. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
  78. package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
  79. package/dist-server/service/chat-session/session-visibility.js +28 -3
  80. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  81. package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
  82. package/dist-server/service/chat-session/session-write-gate.js +1 -1
  83. package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
  84. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  85. package/dist-server/service/chat-session-participant/index.js +4 -1
  86. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  87. package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
  88. package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
  89. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  90. package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
  91. package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
  92. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
  93. package/dist-server/service/chat-session-participant/read-roster.js +5 -2
  94. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
  95. package/dist-server/service/chat-session-resolver.d.ts +27 -0
  96. package/dist-server/service/chat-session-resolver.js +226 -25
  97. package/dist-server/service/chat-session-resolver.js.map +1 -1
  98. package/dist-server/service/data-entry/entry-target.d.ts +6 -0
  99. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  100. package/dist-server/service/data-entry/entry-tools.js +2 -2
  101. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  102. package/dist-server/service/data-entry/entry-update.js +1 -1
  103. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  104. package/dist-server/service/data-read/list-read.d.ts +10 -0
  105. package/dist-server/service/data-read/list-read.js +12 -0
  106. package/dist-server/service/data-read/list-read.js.map +1 -1
  107. package/dist-server/service/data-read/read-target.d.ts +5 -0
  108. package/dist-server/service/data-read/read-target.js.map +1 -1
  109. package/dist-server/service/index.d.ts +4 -2
  110. package/dist-server/service/index.js +9 -1
  111. package/dist-server/service/index.js.map +1 -1
  112. package/dist-server/service/testing/conversation-script.d.ts +1 -1
  113. package/dist-server/service/testing/conversation-script.js.map +1 -1
  114. package/dist-server/service/testing/mode-conformance.js +2 -0
  115. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  116. package/dist-server/tsconfig.tsbuildinfo +1 -1
  117. package/package.json +2 -2
  118. package/server/service/assistant-chat-resolver.ts +7 -12
  119. package/server/service/assistant-mode/index.ts +1 -1
  120. package/server/service/assistant-mode/mode-categories.ts +11 -3
  121. package/server/service/assistant-mode/mode-front.test.ts +53 -1
  122. package/server/service/assistant-mode/screen-rules.test.ts +7 -1
  123. package/server/service/assistant-mode/screen-rules.ts +11 -2
  124. package/server/service/chat-message/chat-message-room.test.ts +7 -2
  125. package/server/service/chat-message/chat-message-room.ts +3 -1
  126. package/server/service/chat-message/chat-typing-subscription.ts +52 -0
  127. package/server/service/chat-message/chat-typing.test.ts +45 -0
  128. package/server/service/chat-message/chat-typing.ts +48 -0
  129. package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
  130. package/server/service/chat-session/attachment-tool-result.ts +7 -0
  131. package/server/service/chat-session/attachment-tools.ts +3 -3
  132. package/server/service/chat-session/chat-session.ts +3 -2
  133. package/server/service/chat-session/group-room.test.ts +233 -0
  134. package/server/service/chat-session/session-activity-broadcast.ts +20 -0
  135. package/server/service/chat-session/session-activity-publish.ts +6 -1
  136. package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
  137. package/server/service/chat-session/session-visibility.test.ts +32 -1
  138. package/server/service/chat-session/session-visibility.ts +33 -4
  139. package/server/service/chat-session/session-write-gate.test.ts +7 -1
  140. package/server/service/chat-session/session-write-gate.ts +2 -2
  141. package/server/service/chat-session-participant/index.ts +2 -0
  142. package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
  143. package/server/service/chat-session-participant/invite-notification.ts +43 -5
  144. package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
  145. package/server/service/chat-session-participant/mention-invite.ts +82 -28
  146. package/server/service/chat-session-participant/read-roster.test.ts +13 -4
  147. package/server/service/chat-session-participant/read-roster.ts +6 -3
  148. package/server/service/chat-session-resolver.ts +204 -27
  149. package/server/service/data-entry/data-entry-update.test.ts +8 -0
  150. package/server/service/data-entry/entry-target.ts +6 -0
  151. package/server/service/data-entry/entry-tools.ts +2 -2
  152. package/server/service/data-entry/entry-update.ts +1 -1
  153. package/server/service/data-read/list-missing.test.ts +49 -0
  154. package/server/service/data-read/list-read.ts +18 -0
  155. package/server/service/data-read/read-target.ts +5 -0
  156. package/server/service/index.ts +6 -1
  157. package/server/service/testing/conversation-script.ts +1 -1
  158. package/server/service/testing/mode-conformance.ts +1 -0
  159. package/test/assistant-dock.test.ts +5 -1
  160. package/test/assistant-session-manager.test.ts +4 -2
  161. package/test/open-doors.test.ts +10 -5
  162. package/translations/en.json +29 -4
  163. package/translations/es.json +29 -4
  164. package/translations/ja.json +29 -4
  165. package/translations/ko.json +29 -4
  166. package/translations/ms.json +29 -4
  167. package/translations/zh.json +29 -4
@@ -1,5 +1,8 @@
1
1
  import { replyDestination, quotedReply } from './reply-message.js'
2
- import { rosterInviteState, rosterName } from './roster-identity'
2
+ import { rosterName } from './roster-identity'
3
+ import { notify } from '@operato/layout'
4
+ import './assistant-invite.js'
5
+ import { TypingRoom } from '../utils/assistant-typing.js'
3
6
  /**
4
7
  * <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
5
8
  *
@@ -336,6 +339,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql`
336
339
  // C4 presence/roster — 세션 참여자 목록 + 마지막 접속(online 판정용).
337
340
  const CHAT_PARTICIPANTS_QUERY = gql`
338
341
  query ChatSessionParticipants($sessionId: String!) {
342
+ mayInviteToAISession(sessionId: $sessionId)
339
343
  chatSessionParticipants(sessionId: $sessionId) {
340
344
  id
341
345
  role
@@ -344,10 +348,38 @@ const CHAT_PARTICIPANTS_QUERY = gql`
344
348
  id
345
349
  name
346
350
  username
351
+ email
347
352
  }
348
353
  }
349
354
  }
350
355
  `
356
+ /*
357
+ * A group conversation (ADR-0073 decision 3) - bringing someone in and sending them out, and the room's
358
+ * "typing" line. The doors decide who may; the roster only offers what the server said this person may do.
359
+ */
360
+ const INVITE_MUTATION = gql`
361
+ mutation InviteToAISession($sessionId: String!, $userIds: [String!]!) {
362
+ inviteToAISession(sessionId: $sessionId, userIds: $userIds)
363
+ }
364
+ `
365
+ const REMOVE_MUTATION = gql`
366
+ mutation RemoveFromAISession($sessionId: String!, $userId: String!) {
367
+ removeFromAISession(sessionId: $sessionId, userId: $userId)
368
+ }
369
+ `
370
+ const SAY_TYPING_MUTATION = gql`
371
+ mutation SayTyping($sessionId: String!) {
372
+ sayTyping(sessionId: $sessionId)
373
+ }
374
+ `
375
+ const CHAT_TYPING_SUBSCRIPTION = gql`
376
+ subscription ChatTyping($sessionId: String!) {
377
+ chatTyping(sessionId: $sessionId) {
378
+ userId
379
+ name
380
+ }
381
+ }
382
+ `
351
383
  const TOUCH_PRESENCE_MUTATION = gql`
352
384
  mutation TouchPresence($sessionId: String!) {
353
385
  touchPresence(sessionId: $sessionId)
@@ -365,6 +397,8 @@ export class OxAssistantChat extends LitElement {
365
397
  @property({ type: String, attribute: 'session-id' })
366
398
  sessionId?: string
367
399
  @property({ attribute: false }) prepareSession?: (adopt: (id: string) => void) => Promise<string | undefined>
400
+ /** Who reads this conversation - 'open' · 'group' · 'private'. The host passes what its list says; the roster follows it. */
401
+ @property({ type: String }) visibility?: string
368
402
  private adoptingSession = false
369
403
 
370
404
  /**
@@ -939,8 +973,17 @@ export class OxAssistantChat extends LitElement {
939
973
 
940
974
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
941
975
  @state()
942
- private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string } }> = []
976
+ private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string; email?: string } }> = []
943
977
  @state() private rosterLoaded = false
978
+ /** May I bring someone in here - the server's answer (`mayInviteToAISession`); unknown until the roster is read. */
979
+ @state() private mayInvite?: boolean
980
+ /** The name box under the roster is open. */
981
+ @state() private inviting = false
982
+ /** Who is typing now, by name - kept by `typingRoom`, redrawn on each notice and as they fade. */
983
+ @state() private typingNames: string[] = []
984
+ private typingRoom = new TypingRoom()
985
+ private _typingSub?: { unsubscribe: () => void }
986
+ private _typingTimer?: ReturnType<typeof setInterval>
944
987
 
945
988
  /** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
946
989
  @state()
@@ -1209,6 +1252,52 @@ export class OxAssistantChat extends LitElement {
1209
1252
  .roster-chip.online .roster-dot {
1210
1253
  background: #22c55e;
1211
1254
  }
1255
+ .roster-remove {
1256
+ display: inline-flex;
1257
+ border: 0;
1258
+ padding: 0;
1259
+ margin-left: 1px;
1260
+ background: none;
1261
+ color: inherit;
1262
+ opacity: 0.6;
1263
+ cursor: pointer;
1264
+ border-radius: 50%;
1265
+ }
1266
+ .roster-remove:hover {
1267
+ opacity: 1;
1268
+ }
1269
+ .roster-remove md-icon {
1270
+ --md-icon-size: 11px;
1271
+ }
1272
+ .roster-invite {
1273
+ display: inline-flex;
1274
+ align-items: center;
1275
+ gap: 2px;
1276
+ padding: 1px 7px 1px 5px;
1277
+ border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1);
1278
+ border-radius: 9px;
1279
+ background: none;
1280
+ color: inherit;
1281
+ font: inherit;
1282
+ font-size: 10px;
1283
+ cursor: pointer;
1284
+ }
1285
+ .roster-invite:hover {
1286
+ background: var(--md-sys-color-surface-container-high, #eef2f7);
1287
+ }
1288
+ .roster-invite md-icon {
1289
+ --md-icon-size: 12px;
1290
+ }
1291
+ .typing-line .typing-dots { gap: 3px; }
1292
+ .typing-line .typing-dots > span { width: 4px; height: 4px; }
1293
+ .typing-line {
1294
+ display: flex;
1295
+ align-items: center;
1296
+ gap: 6px;
1297
+ margin: 0 2px 4px;
1298
+ color: var(--md-sys-color-on-surface-variant, #475569);
1299
+ font-size: 11.5px;
1300
+ }
1212
1301
 
1213
1302
  /* ── Session tabs ─────────────────────────────────── */
1214
1303
  .session-tabs {
@@ -2920,6 +3009,10 @@ export class OxAssistantChat extends LitElement {
2920
3009
  this.input = ''
2921
3010
  this.participants = []
2922
3011
  this.rosterLoaded = false
3012
+ this.mayInvite = undefined
3013
+ this.inviting = false
3014
+ this.typingRoom.clear()
3015
+ this.typingNames = []
2923
3016
  this.replyingTo = undefined
2924
3017
  this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
2925
3018
  if (this.sessionId) {
@@ -2992,6 +3085,7 @@ export class OxAssistantChat extends LitElement {
2992
3085
  private _startMessageSubscription() {
2993
3086
  this._stopMessageSubscription()
2994
3087
  if (!this.sessionId) return
3088
+ this._startTypingSubscription()
2995
3089
  try {
2996
3090
  this._messageSub = client
2997
3091
  .subscribe({ query: CHAT_MESSAGE_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
@@ -3006,7 +3100,47 @@ export class OxAssistantChat extends LitElement {
3006
3100
  }
3007
3101
  }
3008
3102
 
3103
+ /** The room's "typing" line - notices of the others, which fade by themselves (`TypingRoom`). */
3104
+ private _startTypingSubscription() {
3105
+ try {
3106
+ this._typingSub = client
3107
+ .subscribe({ query: CHAT_TYPING_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
3108
+ .subscribe({
3109
+ next: ({ data }: any) => {
3110
+ if (!data?.chatTyping) return
3111
+ this.typingRoom.heard(data.chatTyping)
3112
+ this._refreshTyping()
3113
+ },
3114
+ error: () => {
3115
+ /* no typing line - the conversation itself is unaffected */
3116
+ }
3117
+ })
3118
+ } catch {
3119
+ /* subscriptions unavailable */
3120
+ }
3121
+ }
3122
+
3123
+ private _refreshTyping() {
3124
+ this.typingNames = this.typingRoom.typing()
3125
+ if (this.typingNames.length && !this._typingTimer) {
3126
+ this._typingTimer = setInterval(() => this._refreshTyping(), 1_000)
3127
+ } else if (!this.typingNames.length && this._typingTimer) {
3128
+ clearInterval(this._typingTimer)
3129
+ this._typingTimer = undefined
3130
+ }
3131
+ }
3132
+
3009
3133
  private _stopMessageSubscription() {
3134
+ try {
3135
+ this._typingSub?.unsubscribe()
3136
+ } catch {
3137
+ /* already closed */
3138
+ }
3139
+ this._typingSub = undefined
3140
+ if (this._typingTimer) {
3141
+ clearInterval(this._typingTimer)
3142
+ this._typingTimer = undefined
3143
+ }
3010
3144
  if (this._messageSub) {
3011
3145
  try {
3012
3146
  this._messageSub.unsubscribe()
@@ -3027,6 +3161,8 @@ export class OxAssistantChat extends LitElement {
3027
3161
  * 자기 발신 메시지의 id 는 전송 reconcile 에서 이미 _seenMessageIds 에 등록됨.
3028
3162
  */
3029
3163
  private _onRemoteMessage(m: any) {
3164
+ /* Their words arrived - they are not typing them any more. */
3165
+ if (this.typingRoom.spoke(m?.creator?.id)) this._refreshTyping()
3030
3166
  const next = receiveRemoteMessage(this.lines as any, this._seenMessageIds, m)
3031
3167
  if (!next) return
3032
3168
  this._seenMessageIds = next.seen
@@ -3050,6 +3186,7 @@ export class OxAssistantChat extends LitElement {
3050
3186
  const result = await client.query({query:CHAT_PARTICIPANTS_QUERY,variables:{sessionId},fetchPolicy:'network-only'})
3051
3187
  if (this.sessionId !== sessionId) return
3052
3188
  this.participants=result.data?.chatSessionParticipants??[]
3189
+ this.mayInvite=result.data?.mayInviteToAISession === true
3053
3190
  this.rosterLoaded=true
3054
3191
  } catch {
3055
3192
  if (this.sessionId === sessionId) this.rosterLoaded=false
@@ -3327,6 +3464,7 @@ export class OxAssistantChat extends LitElement {
3327
3464
  <button type="button" aria-label=${i18next.t('ai-assistant.button.cancel-reply')}
3328
3465
  @click=${() => this.cancelReply()}><md-icon>close</md-icon></button>
3329
3466
  </div>` : nothing}
3467
+ ${this.renderTyping()}
3330
3468
  ${this.renderInviteNotice()}
3331
3469
  ${this.addressesPeople
3332
3470
  ? html`<div class="send-destination" aria-live="polite">
@@ -3509,7 +3647,8 @@ export class OxAssistantChat extends LitElement {
3509
3647
  .map(([token]) => token)
3510
3648
  if (incoming.length === 0) return nothing
3511
3649
 
3512
- const inviteState = rosterInviteState(this.participants,this._myUserId,this.rosterLoaded)
3650
+ /* What the server said this person may do here - no copy of the rule on the screen (`maySeat`). */
3651
+ const inviteState = !this.rosterLoaded || this.mayInvite === undefined ? 'checking' : this.mayInvite ? 'allowed' : 'denied'
3513
3652
  const mayInvite = inviteState === 'allowed'
3514
3653
  const names = incoming.map(token => `@${token}`).join(', ')
3515
3654
 
@@ -3522,10 +3661,15 @@ export class OxAssistantChat extends LitElement {
3522
3661
  names,
3523
3662
  defaultValue: '{names} will join this conversation when you send'
3524
3663
  })
3525
- : i18next.t('ai-assistant.text.cannot-invite', {
3526
- names,
3527
- defaultValue: '{names} will not join — only the people already in this conversation can bring someone in'
3528
- })}
3664
+ : this.visibility === 'private'
3665
+ ? i18next.t('ai-assistant.text.cannot-invite-private', {
3666
+ names,
3667
+ defaultValue: '{names} will not join — a private conversation is yours alone. Make it a group to bring people in'
3668
+ })
3669
+ : i18next.t('ai-assistant.text.cannot-invite-group', {
3670
+ names,
3671
+ defaultValue: '{names} will not join — only the person who started this group can bring someone in'
3672
+ })}
3529
3673
  </span>
3530
3674
  </div>
3531
3675
  `
@@ -3662,6 +3806,7 @@ export class OxAssistantChat extends LitElement {
3662
3806
  private onTextareaInput(e: Event) {
3663
3807
  const ta = e.target as HTMLTextAreaElement
3664
3808
  this.input = ta.value
3809
+ this._sayTyping()
3665
3810
  if (this.inputDebounceTimer) clearTimeout(this.inputDebounceTimer)
3666
3811
  this.inputDebounceTimer = setTimeout(() => {
3667
3812
  this.refreshMentionPopup(ta)
@@ -4018,27 +4163,111 @@ export class OxAssistantChat extends LitElement {
4018
4163
  }
4019
4164
 
4020
4165
  /** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
4166
+ /**
4167
+ * Who is in the room. A group shows it always - its people are the point of it, and the owner brings them in
4168
+ * here; any other room once someone else has joined. The buttons follow what the server said this person may
4169
+ * do (`mayInviteToAISession`, the owner's role); the doors decide again.
4170
+ */
4021
4171
  private renderRoster() {
4022
- if (!this.sessionId || this.participants.length < 2) return nothing
4172
+ if (!this.sessionId) return nothing
4173
+ const group = this.visibility === 'group'
4174
+ if (!group && this.participants.length < 2) return nothing
4023
4175
  const online = this.participants.filter(p => this._isOnline(p.lastSeenAt)).length
4176
+ /* Who I am is my email here, as everywhere in this chat - the credential carries no user id. */
4177
+ const myEmail = this._myEmail
4178
+ const iOwn = !!myEmail && this.participants.some(p => p.role === 'owner' && p.user?.email === myEmail)
4179
+ const inviteLabel = i18next.t('ai-assistant.button.invite', { defaultValue: 'Invite' })
4024
4180
  return html`
4025
- <div class="roster" role="group" aria-label="participants">
4181
+ <div class="roster" role="group" aria-label=${i18next.t('ai-assistant.label.participants', { defaultValue: 'People in this conversation' })}>
4026
4182
  <span class="roster-count" title=${`${online} online / ${this.participants.length} total`}>
4027
4183
  <md-icon>group</md-icon>${online}/${this.participants.length}
4028
4184
  </span>
4029
4185
  ${this.participants.map(p => {
4030
4186
  const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
4031
4187
  const on = this._isOnline(p.lastSeenAt)
4188
+ const removable = iOwn && p.role === 'member' && !!p.user?.id
4032
4189
  return html`<span
4033
4190
  class="roster-chip ${on ? 'online' : 'offline'}"
4034
4191
  title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
4035
4192
  <span class="roster-dot"></span><span class="roster-name">${name}</span>
4193
+ ${removable
4194
+ ? html`<button
4195
+ type="button"
4196
+ class="roster-remove"
4197
+ aria-label=${i18next.t('ai-assistant.button.remove-participant', { name, defaultValue: 'Send {name} out' })}
4198
+ title=${i18next.t('ai-assistant.button.remove-participant', { name, defaultValue: 'Send {name} out' })}
4199
+ @click=${() => this._removeParticipant(p.user!.id, name)}><md-icon>close</md-icon></button>`
4200
+ : nothing}
4036
4201
  </span>`
4037
4202
  })}
4203
+ ${this.mayInvite && !this.inviting
4204
+ ? html`<button type="button" class="roster-invite" @click=${() => (this.inviting = true)}>
4205
+ <md-icon>person_add</md-icon>${inviteLabel}
4206
+ </button>`
4207
+ : nothing}
4038
4208
  </div>
4209
+ ${this.inviting
4210
+ ? html`<ox-assistant-invite
4211
+ .search=${(query: string) => (this.userProvider ? Promise.resolve(this.userProvider(query)) : this.fetchDomainUsers(query))}
4212
+ .exclude=${this.participants.map(p => p.user?.id).filter(Boolean) as string[]}
4213
+ @assistant-invite-pick=${(e: CustomEvent) => this._invite(e.detail.userId, e.detail.name)}
4214
+ @assistant-invite-close=${() => (this.inviting = false)}></ox-assistant-invite>`
4215
+ : nothing}
4039
4216
  `
4040
4217
  }
4041
4218
 
4219
+ /** Bring someone in - the door seats them and tells them (`inviteToAISession`). */
4220
+ private async _invite(userId: string, name: string) {
4221
+ const sessionId = this.sessionId
4222
+ if (!sessionId || !userId) return
4223
+ try {
4224
+ const result = await client.mutate({ mutation: INVITE_MUTATION, variables: { sessionId, userIds: [userId] } })
4225
+ if (result.errors?.length) throw new Error(result.errors[0].message)
4226
+ if (this.sessionId !== sessionId) return
4227
+ this.inviting = false
4228
+ notify({ level: 'info', message: i18next.t('ai-assistant.text.invited', { name, defaultValue: '{name} can now read and write here, and has been told.' }) })
4229
+ await this._loadParticipants()
4230
+ } catch (error) {
4231
+ this.errorMessage = error instanceof Error ? error.message : String(error)
4232
+ }
4233
+ }
4234
+
4235
+ /** Send a member out - in a group they no longer read or write here. Bringing them back is inviting them again. */
4236
+ private async _removeParticipant(userId: string, name: string) {
4237
+ const sessionId = this.sessionId
4238
+ if (!sessionId) return
4239
+ try {
4240
+ const result = await client.mutate({ mutation: REMOVE_MUTATION, variables: { sessionId, userId } })
4241
+ if (result.errors?.length) throw new Error(result.errors[0].message)
4242
+ if (this.sessionId !== sessionId) return
4243
+ notify({ level: 'info', message: i18next.t('ai-assistant.text.removed-participant', { name, defaultValue: '{name} has left this conversation.' }) })
4244
+ await this._loadParticipants()
4245
+ } catch (error) {
4246
+ this.errorMessage = error instanceof Error ? error.message : String(error)
4247
+ }
4248
+ }
4249
+
4250
+ /** Tell the others I am typing - only where someone else could hear it, and not on every key (`TypingRoom`). */
4251
+ private _sayTyping() {
4252
+ const sessionId = this.sessionId
4253
+ if (!sessionId || this.visibility === 'private') return
4254
+ if (this.visibility !== 'open' && this.participants.length < 2) return
4255
+ if (!this.typingRoom.shouldSay(this.input)) return
4256
+ client.mutate({ mutation: SAY_TYPING_MUTATION, variables: { sessionId } }).catch(() => {
4257
+ /* a missed notice is a line that does not show */
4258
+ })
4259
+ }
4260
+
4261
+ /** "Kim is typing…" - over the box, while their notices keep coming. */
4262
+ private renderTyping() {
4263
+ const names = this.typingNames
4264
+ if (!names.length) return nothing
4265
+ const who = names.length > 2 ? i18next.t('ai-assistant.text.typing-many', { count: names.length, defaultValue: '{count} people are typing' }) : i18next.t('ai-assistant.text.typing', { names: names.join(', '), defaultValue: '{names} typing' })
4266
+ return html`<div class="typing-line" role="status" aria-live="polite">
4267
+ <span class="typing-dots"><span></span><span></span><span></span></span><span>${who}</span>
4268
+ </div>`
4269
+ }
4270
+
4042
4271
  private _onSessionTabClick(sessionId: string) {
4043
4272
  if (sessionId === this.sessionId) return
4044
4273
  this.dispatchEvent(
@@ -577,10 +577,12 @@ export class AssistantDock extends localize(i18next)(LitElement) {
577
577
  if (!ask) return
578
578
 
579
579
  if (!ask.continueCurrent && (ask.name || ask.context || ask.transcript?.length)) {
580
- const id = await this.startCarried(ask).catch(error => {
581
- notify({ level: 'error', message: (error as Error).message })
580
+ const id = await this.startCarried(ask).catch(() => {
581
+ notify({ level: 'error', message: t('carry-failed', 'The selected context could not be attached. Nothing was sent. Try again from the original screen.') })
582
582
  return undefined
583
583
  })
584
+ // Never send a context-dependent request into the previous conversation after a failed handoff.
585
+ if (!id) return
584
586
  if (id) {
585
587
  await this.reloadSessions()
586
588
  this.controller.select(id)
@@ -605,10 +607,11 @@ export class AssistantDock extends localize(i18next)(LitElement) {
605
607
  `,
606
608
  variables: { t: anchor.anchorType, i: anchor.anchorId, n: ask.name || null }
607
609
  })
610
+ if (created.errors?.length) throw new Error(created.errors[0].message)
608
611
  const id = created.data?.createAISession?.id
609
- if (!id) return undefined
612
+ if (!id) throw new Error('Session was not created')
610
613
  if (ask.context) {
611
- await client.mutate({
614
+ const attached = await client.mutate({
612
615
  mutation: gql`
613
616
  mutation ($id: String!, $c: String!) {
614
617
  appendSessionNote(sessionId: $id, content: $c)
@@ -616,6 +619,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
616
619
  `,
617
620
  variables: { id, c: ask.context }
618
621
  })
622
+ if (attached.errors?.length) throw new Error(attached.errors[0].message)
619
623
  }
620
624
  if (ask.transcript?.length) {
621
625
  await client.mutate({
@@ -847,6 +851,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
847
851
  html`<ox-assistant-chat
848
852
  ?hidden=${this.showingHistory}
849
853
  .sessionId=${this.sessionId}
854
+ .visibility=${this.audience}
850
855
  .prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
851
856
  .contextProvider=${() => {
852
857
  /* The screen moved on between writing and sending - do not answer about the old one. */
@@ -0,0 +1,138 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next } from '@operato/i18n'
7
+
8
+ export interface InviteCandidate {
9
+ id: string
10
+ name?: string | null
11
+ email?: string | null
12
+ }
13
+
14
+ /**
15
+ * Bring someone into the conversation - a name box under the roster, and the people of the domain who match.
16
+ *
17
+ * It only picks. The chat calls the door (`inviteToAISession`), and the door decides: anyone in an open room, the
18
+ * owner in a group, nobody in a private one. People already in the room are not offered.
19
+ *
20
+ * Events:
21
+ * assistant-invite-pick { userId, name }
22
+ * assistant-invite-close
23
+ */
24
+ @customElement('ox-assistant-invite')
25
+ export class AssistantInvite extends LitElement {
26
+ static styles = css`
27
+ :host { display: block; padding: 4px 8px 6px; border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0); }
28
+ .box { display: flex; align-items: center; gap: 4px; }
29
+ input { flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px; border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1); border-radius: 4px; background: var(--md-sys-color-surface, #fff); color: inherit; }
30
+ button { font: inherit; cursor: pointer; }
31
+ .close { border: 0; background: none; padding: 2px; border-radius: 4px; color: var(--md-sys-color-on-surface-variant); line-height: 0; }
32
+ .close md-icon { --md-icon-size: 16px; }
33
+ ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 160px; overflow: auto; }
34
+ li button { display: flex; gap: 6px; align-items: baseline; width: 100%; text-align: start; border: 0; border-radius: 4px; padding: 3px 6px; background: none; color: inherit; font-size: 12px; }
35
+ li button:hover, li button.active { background: var(--md-sys-color-surface-container-high, #eef2f7); }
36
+ .email { font-size: 11px; color: var(--md-sys-color-on-surface-variant); }
37
+ .note { margin-top: 4px; font-size: 11px; color: var(--md-sys-color-on-surface-variant); }
38
+ `
39
+
40
+ /** Finds the people of the domain by a part of a name or an email. */
41
+ @property({ attribute: false }) search?: (query: string) => Promise<InviteCandidate[]>
42
+ /** Ids already in the room - not offered. */
43
+ @property({ attribute: false }) exclude: string[] = []
44
+
45
+ @state() private query = ''
46
+ @state() private found: InviteCandidate[] = []
47
+ @state() private active = 0
48
+ @state() private searched = false
49
+
50
+ private generation = 0
51
+ private timer?: ReturnType<typeof setTimeout>
52
+
53
+ protected firstUpdated() {
54
+ this.renderRoot.querySelector('input')?.focus()
55
+ void this.lookUp()
56
+ }
57
+
58
+ protected updated(changed: PropertyValues) {
59
+ if (changed.has('exclude') && changed.get('exclude') !== undefined) void this.lookUp()
60
+ }
61
+
62
+ disconnectedCallback() {
63
+ clearTimeout(this.timer)
64
+ this.generation++
65
+ super.disconnectedCallback()
66
+ }
67
+
68
+ private async lookUp() {
69
+ const generation = ++this.generation
70
+ const people = this.search ? await this.search(this.query.trim()).catch(() => []) : []
71
+ if (generation !== this.generation) return
72
+ const here = new Set(this.exclude)
73
+ this.found = people.filter(person => person?.id && !here.has(person.id)).slice(0, 8)
74
+ this.active = 0
75
+ this.searched = true
76
+ }
77
+
78
+ private onInput(value: string) {
79
+ this.query = value
80
+ clearTimeout(this.timer)
81
+ this.timer = setTimeout(() => void this.lookUp(), 150)
82
+ }
83
+
84
+ private onKeyDown(event: KeyboardEvent) {
85
+ if (event.key === 'Escape') {
86
+ event.stopPropagation()
87
+ this.emit('assistant-invite-close')
88
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
89
+ event.preventDefault()
90
+ const step = event.key === 'ArrowDown' ? 1 : -1
91
+ if (this.found.length) this.active = (this.active + step + this.found.length) % this.found.length
92
+ } else if (event.key === 'Enter' && !event.isComposing) {
93
+ event.preventDefault()
94
+ const person = this.found[this.active]
95
+ if (person) this.pick(person)
96
+ }
97
+ }
98
+
99
+ private pick(person: InviteCandidate) {
100
+ this.emit('assistant-invite-pick', { userId: person.id, name: person.name || person.email || '' })
101
+ }
102
+
103
+ private emit(name: string, detail?: unknown) {
104
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
105
+ }
106
+
107
+ render() {
108
+ const label = i18next.t('ai-assistant.text.invite-search', { defaultValue: 'Name or email' })
109
+ return html`
110
+ <div class="box">
111
+ <input
112
+ .value=${this.query}
113
+ placeholder=${label}
114
+ aria-label=${label}
115
+ @input=${(e: Event) => this.onInput((e.target as HTMLInputElement).value)}
116
+ @keydown=${(e: KeyboardEvent) => this.onKeyDown(e)} />
117
+ <button
118
+ class="close"
119
+ aria-label=${i18next.t('ai-assistant.button.close')}
120
+ title=${i18next.t('ai-assistant.button.close')}
121
+ @click=${() => this.emit('assistant-invite-close')}><md-icon>close</md-icon></button>
122
+ </div>
123
+ ${this.found.length
124
+ ? html`<ul role="listbox" aria-label=${label}>
125
+ ${this.found.map(
126
+ (person, i) => html`<li>
127
+ <button role="option" aria-selected=${i === this.active ? 'true' : 'false'} class=${i === this.active ? 'active' : ''} @click=${() => this.pick(person)}>
128
+ <span>${person.name || person.email}</span>${person.name && person.email ? html`<span class="email">${person.email}</span>` : nothing}
129
+ </button>
130
+ </li>`
131
+ )}
132
+ </ul>`
133
+ : this.searched
134
+ ? html`<div class="note" role="status">${i18next.t('ai-assistant.text.invite-none-found', { defaultValue: 'No one else here by that name.' })}</div>`
135
+ : nothing}
136
+ `
137
+ }
138
+ }