@things-factory/ai-assistant 10.1.125 → 10.1.129

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 (41) hide show
  1. package/client/components/assistant-chat.ts +122 -68
  2. package/client/components/chat-echo-dedup.ts +2 -1
  3. package/client/components/reply-message.test.ts +15 -0
  4. package/client/components/reply-message.ts +11 -0
  5. package/client/components/roster-identity.test.ts +18 -0
  6. package/client/components/roster-identity.ts +10 -0
  7. package/dist-client/components/assistant-chat.d.ts +7 -0
  8. package/dist-client/components/assistant-chat.js +127 -68
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/chat-echo-dedup.d.ts +1 -0
  11. package/dist-client/components/chat-echo-dedup.js +1 -0
  12. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  13. package/dist-client/components/reply-message.d.ts +5 -0
  14. package/dist-client/components/reply-message.js +14 -0
  15. package/dist-client/components/reply-message.js.map +1 -0
  16. package/dist-client/components/reply-message.test.d.ts +1 -0
  17. package/dist-client/components/reply-message.test.js +16 -0
  18. package/dist-client/components/reply-message.test.js.map +1 -0
  19. package/dist-client/components/roster-identity.d.ts +11 -0
  20. package/dist-client/components/roster-identity.js +11 -0
  21. package/dist-client/components/roster-identity.js.map +1 -0
  22. package/dist-client/components/roster-identity.test.d.ts +1 -0
  23. package/dist-client/components/roster-identity.test.js +20 -0
  24. package/dist-client/components/roster-identity.test.js.map +1 -0
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/dist-server/service/chat-session-participant/read-roster.d.ts +9 -0
  27. package/dist-server/service/chat-session-participant/read-roster.js +15 -0
  28. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -0
  29. package/dist-server/service/chat-session-resolver.d.ts +1 -1
  30. package/dist-server/service/chat-session-resolver.js +15 -11
  31. package/dist-server/service/chat-session-resolver.js.map +1 -1
  32. package/dist-server/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +3 -3
  34. package/server/service/chat-session-participant/read-roster.test.ts +18 -0
  35. package/server/service/chat-session-participant/read-roster.ts +14 -0
  36. package/server/service/chat-session-resolver.ts +11 -8
  37. package/translations/en.json +10 -2
  38. package/translations/ja.json +1 -1
  39. package/translations/ko.json +10 -2
  40. package/translations/ms.json +1 -1
  41. package/translations/zh.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { replyDestination, quotedReply } from './reply-message.js'
2
+ import { rosterInviteState, rosterName } from './roster-identity'
1
3
  /**
2
4
  * <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
3
5
  *
@@ -81,6 +83,7 @@ interface ChatLine {
81
83
  /** 보낸 사용자 email/name — 그룹챗 발신자 표시 + 자기 여부 판정.
82
84
  * **email 로 판정한다**: 로그인 식별자라 비어 있지 않고, 시스템 내부 관리키(User.id)를
83
85
  * 클라이언트 판정에 끌어들이지 않는다(관리키 노출이 계약처럼 굳는 것을 피한다). */
86
+ senderId?: string
84
87
  senderEmail?: string
85
88
  senderName?: string
86
89
  /** 메시지 본문. `#token{refid:N}` inline marker 가 포함될 수 있음 (영속된 메시지 / 사용자
@@ -277,8 +280,8 @@ const DOMAIN_USERS_FOR_MENTION_QUERY = gql`
277
280
  `
278
281
 
279
282
  const SAY_TO_ROOM_MUTATION = gql`
280
- mutation SayToRoom($sessionId: String!, $content: String!) {
281
- sayToRoom(sessionId: $sessionId, content: $content)
283
+ mutation SayToRoom($sessionId: String!, $content: String!, $replyToId: String) {
284
+ sayToRoom(sessionId: $sessionId, content: $content, replyToId: $replyToId)
282
285
  }
283
286
  `
284
287
 
@@ -296,6 +299,7 @@ const CHAT_MESSAGES_QUERY = gql`
296
299
  createdAt
297
300
  redacted
298
301
  creator {
302
+ id
299
303
  name
300
304
  email
301
305
  }
@@ -319,6 +323,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql`
319
323
  createdAt
320
324
  redacted
321
325
  creator {
326
+ id
322
327
  name
323
328
  email
324
329
  }
@@ -336,6 +341,7 @@ const CHAT_PARTICIPANTS_QUERY = gql`
336
341
  user {
337
342
  id
338
343
  name
344
+ username
339
345
  }
340
346
  }
341
347
  }
@@ -349,14 +355,7 @@ const TOUCH_PRESENCE_MUTATION = gql`
349
355
  const PRESENCE_HEARTBEAT_MS = 30_000
350
356
  const PRESENCE_TTL_MS = 75_000
351
357
 
352
- /** 이름 → roster chip 이니셜 (한글 1자 / 영문 최대 2자). */
353
- function initials(name: string): string {
354
- const n = (name || '?').trim()
355
- if (!n) return '?'
356
- const parts = n.split(/\s+/)
357
- if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
358
- return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
359
- }
358
+
360
359
 
361
360
  @customElement('ox-assistant-chat')
362
361
  export class OxAssistantChat extends LitElement {
@@ -922,7 +921,8 @@ export class OxAssistantChat extends LitElement {
922
921
 
923
922
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
924
923
  @state()
925
- private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string } }> = []
924
+ private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string } }> = []
925
+ @state() private rosterLoaded = false
926
926
 
927
927
  /** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
928
928
  @state()
@@ -983,6 +983,7 @@ export class OxAssistantChat extends LitElement {
983
983
  * textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
984
984
  * send 시 mutation input.userMentions 로 함께 전송. */
985
985
  private pickedUserMentions: Map<string, string> = new Map()
986
+ @state() private replyingTo?: { id?: string; name: string; excerpt: string; role: string; token?: string }
986
987
 
987
988
  /** input 디바운스 timer — 150ms 후 refreshMentionPopup 실행 (F-Finder 와 동일) */
988
989
  private inputDebounceTimer?: ReturnType<typeof setTimeout>
@@ -1251,6 +1252,7 @@ export class OxAssistantChat extends LitElement {
1251
1252
  font-size: 10px;
1252
1253
  background: var(--md-sys-color-surface-variant, #f1f5f9);
1253
1254
  }
1255
+ .roster-name { max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1254
1256
  .roster-chip.offline {
1255
1257
  opacity: 0.45;
1256
1258
  }
@@ -2760,6 +2762,21 @@ export class OxAssistantChat extends LitElement {
2760
2762
 
2761
2763
  /* ── Input area ───────────────────────────────────── */
2762
2764
  /* border-top 은 .composer 가 담당 (actions-row 가 같은 wrapper 안) */
2765
+ .reply-preview {
2766
+ display: flex; gap: 8px; margin: 4px 14px; padding: 8px 10px;
2767
+ border-left: 3px solid var(--md-sys-color-primary);
2768
+ background: var(--md-sys-color-surface-container, #f1f5f9); border-radius: 6px;
2769
+ }
2770
+ .reply-preview > div { flex: 1; min-width: 0; }
2771
+ .reply-preview strong { display: block; font-size: 12px; }
2772
+ .reply-preview span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
2773
+ .reply-preview button { border: 0; background: transparent; color: inherit; cursor: pointer; }
2774
+ .send-destination {
2775
+ font-size: 11px;
2776
+ color: var(--md-sys-color-on-surface-variant, #475569);
2777
+ align-self: center;
2778
+ max-width: 64px;
2779
+ }
2763
2780
  .input-row {
2764
2781
  display: flex;
2765
2782
  gap: 8px;
@@ -2835,15 +2852,15 @@ export class OxAssistantChat extends LitElement {
2835
2852
  --md-icon-size: 15px;
2836
2853
  }
2837
2854
 
2838
- /* ── Send button — ChatGPT 스타일 원형 ↑ (.input-row 안의 button 만) ─── */
2855
+ /* One send control, aligned with the resting input height. */
2839
2856
  .input-row > button {
2840
- width: 32px;
2841
- height: 32px;
2857
+ width: 54px;
2858
+ height: 54px;
2842
2859
  padding: 0;
2843
2860
  background: var(--md-sys-color-primary, #1e293b);
2844
2861
  color: var(--md-sys-color-on-primary, #ffffff);
2845
2862
  border: 0;
2846
- border-radius: 50%;
2863
+ border-radius: 12px;
2847
2864
  cursor: pointer;
2848
2865
  display: flex;
2849
2866
  align-items: center;
@@ -2854,17 +2871,8 @@ export class OxAssistantChat extends LitElement {
2854
2871
  opacity 0.15s;
2855
2872
  flex-shrink: 0;
2856
2873
  align-self: flex-end;
2857
- margin-bottom: 2px;
2858
- }
2859
- /*
2860
- 방에 말하는 자리는 **두 번째 행동**이다 — 이 화면의 첫 행동은 어시스턴트에게 묻는 것이다.
2861
- 같은 무게로 두면 무엇이 기본인지 화면이 말하지 않게 된다(ADR-0070 A15 의 자리 둘).
2862
- */
2863
- .input-row > button.say-to-room {
2864
- background: var(--md-sys-color-surface-container-highest, #e2e8f0);
2865
- color: var(--md-sys-color-on-surface-variant, #475569);
2874
+ margin-bottom: 0;
2866
2875
  }
2867
-
2868
2876
  .input-row > button:disabled {
2869
2877
  background: var(--md-sys-color-surface-container-highest, #e2e8f0);
2870
2878
  color: var(--md-sys-color-outline, #94a3b8);
@@ -2982,6 +2990,8 @@ export class OxAssistantChat extends LitElement {
2982
2990
  this.errorMessage = undefined
2983
2991
  this.input = ''
2984
2992
  this.participants = []
2993
+ this.rosterLoaded = false
2994
+ this.replyingTo = undefined
2985
2995
  this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
2986
2996
  if (this.sessionId) {
2987
2997
  this.loadHistory()
@@ -3105,16 +3115,15 @@ export class OxAssistantChat extends LitElement {
3105
3115
  }
3106
3116
 
3107
3117
  private async _loadParticipants() {
3108
- if (!this.sessionId) return
3118
+ const sessionId=this.sessionId
3119
+ if (!sessionId) return
3109
3120
  try {
3110
- const result = await client.query({
3111
- query: CHAT_PARTICIPANTS_QUERY,
3112
- variables: { sessionId: this.sessionId },
3113
- fetchPolicy: 'network-only'
3114
- })
3115
- this.participants = result.data?.chatSessionParticipants ?? []
3121
+ const result = await client.query({query:CHAT_PARTICIPANTS_QUERY,variables:{sessionId},fetchPolicy:'network-only'})
3122
+ if (this.sessionId !== sessionId) return
3123
+ this.participants=result.data?.chatSessionParticipants??[]
3124
+ this.rosterLoaded=true
3116
3125
  } catch {
3117
- /* roster 로드 실패는 채팅 본 기능 무영향 — 조용히 무시 */
3126
+ if (this.sessionId === sessionId) this.rosterLoaded=false
3118
3127
  }
3119
3128
  }
3120
3129
 
@@ -3392,6 +3401,12 @@ export class OxAssistantChat extends LitElement {
3392
3401
  </div>
3393
3402
  `
3394
3403
  : nothing}
3404
+ ${this.replyingTo ? html`<div class="reply-preview">
3405
+ <div><strong>${i18next.t('ai-assistant.text.reply-to', { name: this.replyingTo.name })}</strong>
3406
+ <span>${this.replyingTo.excerpt}</span></div>
3407
+ <button type="button" aria-label=${i18next.t('ai-assistant.button.cancel-reply')}
3408
+ @click=${() => this.cancelReply()}><md-icon>close</md-icon></button>
3409
+ </div>` : nothing}
3395
3410
  ${this.renderInviteNotice()}
3396
3411
  <div class="input-row">
3397
3412
  <div class="textarea-wrap">
@@ -3429,33 +3444,15 @@ export class OxAssistantChat extends LitElement {
3429
3444
  `
3430
3445
  : nothing}
3431
3446
  </div>
3432
- ${/*
3433
- 두 자리다 — 어시스턴트에게 묻는 자리와 **방에 말하는 자리**. 누른 자리가 그 말의 대상을
3434
- 정하고, 서버는 어느 문이 불렸는지로 그 사실을 저장한다(ADR-0070 A15 결정 ①).
3435
-
3436
- **방에 사람이 있을 때만 보인다.** 혼자 쓰는 대화에서 「방에 말하기」는 아무에게도 하지 않는
3437
- 말이고, 누를 일이 없는 단추는 무엇이 기본인지 흐린다. 조건은 참여자 목록과 같다(둘 이상) —
3438
- 둘째 사람은 멘션으로 들어오고, 그 순간 이 자리도 함께 선다.
3439
- */ ''}
3440
- ${this.sessionId && this.participants.length >= 2
3441
- ? html`
3442
- <button
3443
- class="say-to-room"
3444
- ?disabled=${this.busy || !this.input.trim()}
3445
- @click=${() => this.sayToRoom()}
3446
- title=${i18next.t('ai-assistant.text.say-to-room-tooltip', {
3447
- defaultValue: 'say this to the people here — the assistant does not answer'
3448
- })}>
3449
- <md-icon>forum</md-icon>
3450
- </button>
3451
- `
3452
- : nothing}
3447
+ <span class="send-destination" aria-live="polite">${i18next.t(this.addressesPeople
3448
+ ? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}</span>
3453
3449
  ${this.composerWide
3454
3450
  ? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
3455
3451
  : nothing}
3456
3452
  <button
3457
3453
  ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3458
- @click=${this.send}
3454
+ @click=${() => this.submitMessage()}
3455
+ aria-label=${i18next.t(this.addressesPeople ? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}
3459
3456
  title=${this.sendBlocked ||
3460
3457
  (this.composerWide
3461
3458
  ? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
@@ -3475,6 +3472,53 @@ export class OxAssistantChat extends LitElement {
3475
3472
  * 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
3476
3473
  * 한 말은 누구에게도 남지 않는다.
3477
3474
  */
3475
+ private startReply(line: ChatLine) {
3476
+ this.cancelReply()
3477
+ const name = line.role === 'assistant' ? 'AI' : line.senderName || i18next.t('ai-assistant.label.participant')
3478
+ const token = line.role === 'user' ? name : undefined
3479
+ this.replyingTo = { id: line.id, name, role: line.role, token,
3480
+ excerpt: stripMentionRefids(line.content).replace(/\{userId:[^}]+\}/g, '').replace(/\s+/g, ' ').slice(0, 180) }
3481
+ if (token && line.senderId) {
3482
+ this.pickedUserMentions.set(token, line.senderId)
3483
+ if (!this.input.includes(`@${token}`)) this.input = `@${token} ${this.input}`
3484
+ }
3485
+ this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
3486
+ }
3487
+
3488
+ private cancelReply() {
3489
+ const token = this.replyingTo?.token
3490
+ if (token && this.input.startsWith(`@${token} `)) this.input = this.input.slice(token.length + 2)
3491
+ this.replyingTo = undefined
3492
+ }
3493
+
3494
+ private replyContent(body: string): string {
3495
+ return quotedReply(body, this.replyingTo)
3496
+ }
3497
+
3498
+ private get addressesPeople(): boolean {
3499
+ const mentioned = [...this.pickedUserMentions.keys()].some(token => this.input.includes(`@${token}`))
3500
+ return replyDestination(this.replyingTo?.role, mentioned) === 'room'
3501
+ }
3502
+
3503
+ private async submitMessage() {
3504
+ if (this.busy || !this.input.trim() && !this.attachments.length) return
3505
+ if (!this.addressesPeople) return this.send()
3506
+ if (this.attachments.length) {
3507
+ this.errorMessage = i18next.t('ai-assistant.text.room-attachments-unavailable')
3508
+ return
3509
+ }
3510
+ if (!this.sessionId && this.prepareSession) {
3511
+ try {
3512
+ await this.prepareSession(id => { this.adoptingSession = true; this.sessionId = id })
3513
+ await this.updateComplete
3514
+ } catch (error) {
3515
+ this.errorMessage = error instanceof Error ? error.message : String(error)
3516
+ return
3517
+ }
3518
+ }
3519
+ return this.sayToRoom()
3520
+ }
3521
+
3478
3522
  private async sayToRoom() {
3479
3523
  const body = this.input.trim()
3480
3524
  if (!body || this.busy) return
@@ -3486,15 +3530,20 @@ export class OxAssistantChat extends LitElement {
3486
3530
  return
3487
3531
  }
3488
3532
 
3533
+ const reply = this.replyingTo
3534
+ const content = this.replyContent(injectMentionUserIds(body, [...this.pickedUserMentions.entries()]
3535
+ .filter(([token]) => body.includes(`@${token}`))
3536
+ .map(([token, userId]) => ({ token, userId }))))
3489
3537
  const localId = `local-${++this._localSeq}-r`
3490
- this.lines = [...this.lines, { role: 'user', content: body, _localId: localId, senderEmail: this._myEmail }]
3538
+ this.lines = [...this.lines, { role: 'user', content, _localId: localId, senderId: this._myUserId, senderEmail: this._myEmail }]
3491
3539
  this.input = ''
3492
3540
  this.busy = true
3493
3541
  try {
3494
3542
  const result = await client.mutate({
3495
3543
  mutation: SAY_TO_ROOM_MUTATION,
3496
- variables: { sessionId: this.sessionId, content: body }
3544
+ variables: { sessionId: this.sessionId, content, replyToId: reply?.id }
3497
3545
  })
3546
+ this.replyingTo = undefined
3498
3547
  /* 방송 에코와 겹치지 않게 서버 id 로 맞춘다 — 보낸 말이 두 줄로 보이지 않게. */
3499
3548
  this._reconcileSentLine(localId, result.data?.sayToRoom ?? undefined, {})
3500
3549
  } catch (error) {
@@ -3524,15 +3573,15 @@ export class OxAssistantChat extends LitElement {
3524
3573
  .map(([token]) => token)
3525
3574
  if (incoming.length === 0) return nothing
3526
3575
 
3527
- const myRole = this.participants.find(p => p.user?.id && p.user.id === this._myUserId)?.role
3528
- const mayInvite = myRole === 'owner' || myRole === 'member'
3576
+ const inviteState = rosterInviteState(this.participants,this._myUserId,this.rosterLoaded)
3577
+ const mayInvite = inviteState === 'allowed'
3529
3578
  const names = incoming.map(token => `@${token}`).join(', ')
3530
3579
 
3531
3580
  return html`
3532
3581
  <div class="invite-notice">
3533
3582
  <md-icon>person_add</md-icon>
3534
3583
  <span>
3535
- ${mayInvite
3584
+ ${inviteState === 'checking' ? i18next.t('ai-assistant.text.invite-checking') : mayInvite
3536
3585
  ? i18next.t('ai-assistant.text.will-be-invited', {
3537
3586
  names,
3538
3587
  defaultValue: '{names} will join this conversation when you send'
@@ -3650,7 +3699,7 @@ export class OxAssistantChat extends LitElement {
3650
3699
  this.setComposerWide(false)
3651
3700
  } else if (action === 'send') {
3652
3701
  e.preventDefault()
3653
- if (!this.busy) this.send()
3702
+ if (!this.busy) void this.submitMessage()
3654
3703
  }
3655
3704
  }
3656
3705
 
@@ -4070,12 +4119,12 @@ export class OxAssistantChat extends LitElement {
4070
4119
  <md-icon>group</md-icon>${online}/${this.participants.length}
4071
4120
  </span>
4072
4121
  ${this.participants.map(p => {
4073
- const name = p.user?.name || p.user?.id || '?'
4122
+ const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
4074
4123
  const on = this._isOnline(p.lastSeenAt)
4075
4124
  return html`<span
4076
4125
  class="roster-chip ${on ? 'online' : 'offline'}"
4077
4126
  title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
4078
- <span class="roster-dot"></span>${initials(name)}
4127
+ <span class="roster-dot"></span><span class="roster-name">${name}</span>
4079
4128
  </span>`
4080
4129
  })}
4081
4130
  </div>
@@ -4227,7 +4276,7 @@ export class OxAssistantChat extends LitElement {
4227
4276
  * system : 없음
4228
4277
  */
4229
4278
  private renderMessageActions(idx: number, line: ChatLine, content: string) {
4230
- if (line.role !== 'assistant') return nothing
4279
+ if (line.role === 'system') return nothing
4231
4280
 
4232
4281
  const isCopied = this.copiedIdx === idx
4233
4282
  // revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
@@ -4241,13 +4290,17 @@ export class OxAssistantChat extends LitElement {
4241
4290
  @click=${() => this.copyMessage(idx, content)}>
4242
4291
  <md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
4243
4292
  </button>
4244
- <button
4293
+ <button class="msg-action" ?disabled=${this.busy || (line.role === 'user' && !line.senderId)}
4294
+ title=${i18next.t('ai-assistant.button.reply')}
4295
+ aria-label=${i18next.t('ai-assistant.button.reply')}
4296
+ @click=${() => this.startReply(line)}><md-icon>reply</md-icon></button>
4297
+ ${line.role === 'assistant' ? html`<button
4245
4298
  class="msg-action"
4246
4299
  ?disabled=${this.busy}
4247
4300
  title=${i18next.t('ai-assistant.button.regenerate')}
4248
4301
  @click=${() => this.regenerateAssistant(idx)}>
4249
4302
  <md-icon>refresh</md-icon>
4250
- </button>
4303
+ </button>` : nothing}
4251
4304
  </div>
4252
4305
  `
4253
4306
  }
@@ -5046,7 +5099,8 @@ export class OxAssistantChat extends LitElement {
5046
5099
  if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
5047
5100
  }
5048
5101
  let enrichedContent = injectMentionRefids(promptText, mentions)
5049
- enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
5102
+ enrichedContent = this.replyContent(injectMentionUserIds(enrichedContent, userMentions))
5103
+ this.replyingTo = undefined
5050
5104
 
5051
5105
  // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
5052
5106
  const userLocalId = `local-${++this._localSeq}-u`
@@ -5065,7 +5119,7 @@ export class OxAssistantChat extends LitElement {
5065
5119
  base64: a.base64
5066
5120
  })),
5067
5121
  _localId: userLocalId,
5068
- senderEmail: this._myEmail
5122
+ senderId: this._myUserId, senderEmail: this._myEmail
5069
5123
  }
5070
5124
  ]
5071
5125
  this.input = ''
@@ -32,7 +32,7 @@ export interface RemoteMessage {
32
32
  /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */
33
33
  groundingWarningsJson?: any
34
34
  /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */
35
- creator?: { name?: string; email?: string } | null
35
+ creator?: { id?: string; name?: string; email?: string } | null
36
36
  /** 이 사람이 이 답의 문을 못 지나 본문이 한 줄로 바뀌어 왔나(ADR-0073 판정 1). */
37
37
  redacted?: boolean
38
38
  }
@@ -51,6 +51,7 @@ export function remoteMessageToLine(m: RemoteMessage): DedupLine {
51
51
  attachments: Array.isArray((m as any).attachmentsJson)
52
52
  ? (m as any).attachmentsJson.map((a: any) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))
53
53
  : undefined,
54
+ senderId: m.creator?.id ?? undefined,
54
55
  senderEmail: m.creator?.email ?? undefined,
55
56
  senderName: m.creator?.name ?? undefined,
56
57
  /*
@@ -0,0 +1,15 @@
1
+ import { replyDestination, quotedReply } from './reply-message'
2
+ describe('message replies', () => {
3
+ it('routes a human reply to the room and an AI reply to AI even with an earlier mention', () => {
4
+ expect(replyDestination('user', false)).toBe('room')
5
+ expect(replyDestination('assistant', true)).toBe('assistant')
6
+ expect(replyDestination(undefined, true)).toBe('room')
7
+ expect(replyDestination(undefined, false)).toBe('assistant')
8
+ })
9
+ it('keeps a short quoted original above the answer and escapes markup', () => {
10
+ expect(quotedReply('answer', {name: 'Kim', excerpt: 'original'})).toBe('> Kim: original\n\nanswer')
11
+ expect(quotedReply('answer')).toBe('answer')
12
+ expect(quotedReply('answer', {name: '<Kim>', excerpt: '**text**'})).toContain('\\<Kim\\>')
13
+ expect(quotedReply('answer', {name: 'Kim', excerpt: '**text**'})).toContain('\\*\\*text\\*\\*')
14
+ })
15
+ })
@@ -0,0 +1,11 @@
1
+ export function replyDestination(role: string | undefined, hasUserMention: boolean): 'room' | 'assistant' {
2
+ if (role === 'assistant') return 'assistant'
3
+ if (role === 'user') return 'room'
4
+ return hasUserMention ? 'room' : 'assistant'
5
+ }
6
+
7
+ export function quotedReply(body: string, reply?: { name: string; excerpt: string }): string {
8
+ if (!reply) return body
9
+ const escape = (text: string) => text.replace(/[\\`*_{}\[\]<>#]/g, char => `\\${char}`)
10
+ return `> ${escape(reply.name)}: ${escape(reply.excerpt)}\n\n${body}`
11
+ }
@@ -0,0 +1,18 @@
1
+ import { rosterInviteState, rosterName } from './roster-identity'
2
+ describe('roster identity and invite notice',()=>{
3
+ it('does not claim denial when participant identities are missing or still loading',()=>{
4
+ expect(rosterInviteState([{role:'owner'}],'me',true)).toBe('checking')
5
+ expect(rosterInviteState([], 'me',false)).toBe('checking')
6
+ expect(rosterInviteState([], undefined,true)).toBe('checking')
7
+ })
8
+ it('uses the current person role rather than another member or a stale loading state',()=>{
9
+ for(const role of ['owner','member'])expect(rosterInviteState([{role,user:{id:'me'}}],'me',true)).toBe('allowed')
10
+ expect(rosterInviteState([{role:'viewer',user:{id:'me'}},{role:'owner',user:{id:'other'}}],'me',true)).toBe('denied')
11
+ expect(rosterInviteState([{role:'member',user:{id:'other'}}],'me',true)).toBe('denied')
12
+ })
13
+ it('shows a recognizable full name, then username, then an explicit unavailable label',()=>{
14
+ expect(rosterName({role:'member',user:{id:'u',name:' 정연희 '}},'Unavailable')).toBe('정연희')
15
+ expect(rosterName({role:'member',user:{id:'u',username:'yeonhee'}},'Unavailable')).toBe('yeonhee')
16
+ expect(rosterName({role:'member'},'Unavailable')).toBe('Unavailable')
17
+ })
18
+ })
@@ -0,0 +1,10 @@
1
+ export interface RosterParticipant { role: string; user?: { id: string; name?: string; username?: string } }
2
+ /** Missing identity/loading is not evidence that a person lacks membership. Server gates remain authoritative. */
3
+ export function rosterInviteState(rows: readonly RosterParticipant[], userId: string | undefined, loaded: boolean): 'checking' | 'allowed' | 'denied' {
4
+ if (!loaded || !userId || rows.some(row => !row.user?.id)) return 'checking'
5
+ const role = rows.find(row => row.user?.id === userId)?.role
6
+ return role === 'owner' || role === 'member' ? 'allowed' : 'denied'
7
+ }
8
+ export function rosterName(person: RosterParticipant, fallback: string): string {
9
+ return person.user?.name?.trim() || person.user?.username?.trim() || fallback
10
+ }
@@ -301,6 +301,7 @@ export declare class OxAssistantChat extends LitElement {
301
301
  private errorMessage?;
302
302
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
303
303
  private participants;
304
+ private rosterLoaded;
304
305
  /** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
305
306
  private _presenceTick;
306
307
  private _heartbeatTimer?;
@@ -338,6 +339,7 @@ export declare class OxAssistantChat extends LitElement {
338
339
  * textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
339
340
  * send 시 mutation input.userMentions 로 함께 전송. */
340
341
  private pickedUserMentions;
342
+ private replyingTo?;
341
343
  /** input 디바운스 timer — 150ms 후 refreshMentionPopup 실행 (F-Finder 와 동일) */
342
344
  private inputDebounceTimer?;
343
345
  /** centerTo 디바운스 timer — arrow nav / hover 빠를 때 카메라 애니메이션 race 방지 */
@@ -431,6 +433,11 @@ export declare class OxAssistantChat extends LitElement {
431
433
  * 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
432
434
  * 한 말은 누구에게도 남지 않는다.
433
435
  */
436
+ private startReply;
437
+ private cancelReply;
438
+ private replyContent;
439
+ private get addressesPeople();
440
+ private submitMessage;
434
441
  private sayToRoom;
435
442
  /**
436
443
  * 보내기 전에 말한다 — 「이 사람은 이 대화에 들어옵니다」(ADR-0071 결정 1 ④).