@things-factory/ai-assistant 10.1.123 → 10.1.127

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 (42) hide show
  1. package/client/components/assistant-chat.ts +145 -95
  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/client/utils/assistant-navigation.test.ts +22 -0
  8. package/dist-client/components/assistant-chat.d.ts +7 -0
  9. package/dist-client/components/assistant-chat.js +148 -94
  10. package/dist-client/components/assistant-chat.js.map +1 -1
  11. package/dist-client/components/chat-echo-dedup.d.ts +1 -0
  12. package/dist-client/components/chat-echo-dedup.js +1 -0
  13. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  14. package/dist-client/components/reply-message.d.ts +5 -0
  15. package/dist-client/components/reply-message.js +14 -0
  16. package/dist-client/components/reply-message.js.map +1 -0
  17. package/dist-client/components/reply-message.test.d.ts +1 -0
  18. package/dist-client/components/reply-message.test.js +16 -0
  19. package/dist-client/components/reply-message.test.js.map +1 -0
  20. package/dist-client/components/roster-identity.d.ts +11 -0
  21. package/dist-client/components/roster-identity.js +11 -0
  22. package/dist-client/components/roster-identity.js.map +1 -0
  23. package/dist-client/components/roster-identity.test.d.ts +1 -0
  24. package/dist-client/components/roster-identity.test.js +20 -0
  25. package/dist-client/components/roster-identity.test.js.map +1 -0
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/dist-client/utils/assistant-navigation.test.js +20 -0
  28. package/dist-client/utils/assistant-navigation.test.js.map +1 -1
  29. package/dist-server/service/chat-session-participant/read-roster.d.ts +9 -0
  30. package/dist-server/service/chat-session-participant/read-roster.js +15 -0
  31. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -0
  32. package/dist-server/service/chat-session-resolver.d.ts +1 -1
  33. package/dist-server/service/chat-session-resolver.js +15 -11
  34. package/dist-server/service/chat-session-resolver.js.map +1 -1
  35. package/dist-server/tsconfig.tsbuildinfo +1 -1
  36. package/package.json +2 -2
  37. package/server/service/chat-session-participant/read-roster.test.ts +18 -0
  38. package/server/service/chat-session-participant/read-roster.ts +14 -0
  39. package/server/service/chat-session-resolver.ts +11 -8
  40. package/test/assistant-request-context.test.ts +3 -1
  41. package/translations/en.json +9 -1
  42. package/translations/ko.json +9 -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);
@@ -2931,33 +2939,6 @@ export class OxAssistantChat extends LitElement {
2931
2939
  updated(changed: Map<string, any>) {
2932
2940
  if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
2933
2941
 
2934
- /*
2935
- * 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
2936
- *
2937
- * sessionId 가 붙기 전에 보내면 대화에 남지 않으므로 세션이 준비될 때까지 기다린다.
2938
- * 같은 문장을 두 번 보내지 않도록 보낸 값을 기억한다 — 재렌더마다 updated 가 다시 돌기 때문이다.
2939
- */
2940
- if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
2941
- this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
2942
- this.input = this.autoAsk
2943
- this.send()
2944
- }
2945
-
2946
- /* A draft fills the input and waits for the person — it is never sent from here. */
2947
- const draft = assistantDraftDecision({
2948
- draft: this.draft,
2949
- draftRequestId: this.draftRequestId,
2950
- drafted: this._drafted,
2951
- typed: this.input,
2952
- autoAsk: this.autoAsk,
2953
- autoAskRequestId: this.autoAskRequestId
2954
- })
2955
- if (draft.consumed) this._drafted = draft.consumed
2956
- if (draft.fill) {
2957
- this.input = this.draft!
2958
- this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
2959
- }
2960
-
2961
2942
  /*
2962
2943
  * The subject changed, so the conversation is over.
2963
2944
  *
@@ -3009,6 +2990,8 @@ export class OxAssistantChat extends LitElement {
3009
2990
  this.errorMessage = undefined
3010
2991
  this.input = ''
3011
2992
  this.participants = []
2993
+ this.rosterLoaded = false
2994
+ this.replyingTo = undefined
3012
2995
  this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
3013
2996
  if (this.sessionId) {
3014
2997
  this.loadHistory()
@@ -3019,6 +3002,29 @@ export class OxAssistantChat extends LitElement {
3019
3002
  this._stopMessageSubscription()
3020
3003
  }
3021
3004
  }
3005
+ /* Send carried questions only after subject, mode and session cleanup has completed. */
3006
+ if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
3007
+ this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
3008
+ this.input = this.autoAsk
3009
+ void this.send()
3010
+ }
3011
+
3012
+ /* A draft fills the input and waits for the person — it is never sent from here. */
3013
+ const draft = assistantDraftDecision({
3014
+ draft: this.draft,
3015
+ draftRequestId: this.draftRequestId,
3016
+ drafted: this._drafted,
3017
+ typed: this.input,
3018
+ autoAsk: this.autoAsk,
3019
+ autoAskRequestId: this.autoAskRequestId
3020
+ })
3021
+ if (draft.consumed) this._drafted = draft.consumed
3022
+ if (draft.fill) {
3023
+ this.input = this.draft!
3024
+ this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
3025
+ }
3026
+
3027
+
3022
3028
  // 자동 스크롤
3023
3029
  const ms = this.renderRoot.querySelector('.messages') as HTMLElement | null
3024
3030
  if (ms) ms.scrollTop = ms.scrollHeight
@@ -3109,16 +3115,15 @@ export class OxAssistantChat extends LitElement {
3109
3115
  }
3110
3116
 
3111
3117
  private async _loadParticipants() {
3112
- if (!this.sessionId) return
3118
+ const sessionId=this.sessionId
3119
+ if (!sessionId) return
3113
3120
  try {
3114
- const result = await client.query({
3115
- query: CHAT_PARTICIPANTS_QUERY,
3116
- variables: { sessionId: this.sessionId },
3117
- fetchPolicy: 'network-only'
3118
- })
3119
- 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
3120
3125
  } catch {
3121
- /* roster 로드 실패는 채팅 본 기능 무영향 — 조용히 무시 */
3126
+ if (this.sessionId === sessionId) this.rosterLoaded=false
3122
3127
  }
3123
3128
  }
3124
3129
 
@@ -3396,6 +3401,12 @@ export class OxAssistantChat extends LitElement {
3396
3401
  </div>
3397
3402
  `
3398
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}
3399
3410
  ${this.renderInviteNotice()}
3400
3411
  <div class="input-row">
3401
3412
  <div class="textarea-wrap">
@@ -3433,33 +3444,15 @@ export class OxAssistantChat extends LitElement {
3433
3444
  `
3434
3445
  : nothing}
3435
3446
  </div>
3436
- ${/*
3437
- 두 자리다 — 어시스턴트에게 묻는 자리와 **방에 말하는 자리**. 누른 자리가 그 말의 대상을
3438
- 정하고, 서버는 어느 문이 불렸는지로 그 사실을 저장한다(ADR-0070 A15 결정 ①).
3439
-
3440
- **방에 사람이 있을 때만 보인다.** 혼자 쓰는 대화에서 「방에 말하기」는 아무에게도 하지 않는
3441
- 말이고, 누를 일이 없는 단추는 무엇이 기본인지 흐린다. 조건은 참여자 목록과 같다(둘 이상) —
3442
- 둘째 사람은 멘션으로 들어오고, 그 순간 이 자리도 함께 선다.
3443
- */ ''}
3444
- ${this.sessionId && this.participants.length >= 2
3445
- ? html`
3446
- <button
3447
- class="say-to-room"
3448
- ?disabled=${this.busy || !this.input.trim()}
3449
- @click=${() => this.sayToRoom()}
3450
- title=${i18next.t('ai-assistant.text.say-to-room-tooltip', {
3451
- defaultValue: 'say this to the people here — the assistant does not answer'
3452
- })}>
3453
- <md-icon>forum</md-icon>
3454
- </button>
3455
- `
3456
- : 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>
3457
3449
  ${this.composerWide
3458
3450
  ? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
3459
3451
  : nothing}
3460
3452
  <button
3461
3453
  ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3462
- @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')}
3463
3456
  title=${this.sendBlocked ||
3464
3457
  (this.composerWide
3465
3458
  ? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
@@ -3479,6 +3472,53 @@ export class OxAssistantChat extends LitElement {
3479
3472
  * 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
3480
3473
  * 한 말은 누구에게도 남지 않는다.
3481
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
+
3482
3522
  private async sayToRoom() {
3483
3523
  const body = this.input.trim()
3484
3524
  if (!body || this.busy) return
@@ -3490,15 +3530,20 @@ export class OxAssistantChat extends LitElement {
3490
3530
  return
3491
3531
  }
3492
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 }))))
3493
3537
  const localId = `local-${++this._localSeq}-r`
3494
- 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 }]
3495
3539
  this.input = ''
3496
3540
  this.busy = true
3497
3541
  try {
3498
3542
  const result = await client.mutate({
3499
3543
  mutation: SAY_TO_ROOM_MUTATION,
3500
- variables: { sessionId: this.sessionId, content: body }
3544
+ variables: { sessionId: this.sessionId, content, replyToId: reply?.id }
3501
3545
  })
3546
+ this.replyingTo = undefined
3502
3547
  /* 방송 에코와 겹치지 않게 서버 id 로 맞춘다 — 보낸 말이 두 줄로 보이지 않게. */
3503
3548
  this._reconcileSentLine(localId, result.data?.sayToRoom ?? undefined, {})
3504
3549
  } catch (error) {
@@ -3528,15 +3573,15 @@ export class OxAssistantChat extends LitElement {
3528
3573
  .map(([token]) => token)
3529
3574
  if (incoming.length === 0) return nothing
3530
3575
 
3531
- const myRole = this.participants.find(p => p.user?.id && p.user.id === this._myUserId)?.role
3532
- const mayInvite = myRole === 'owner' || myRole === 'member'
3576
+ const inviteState = rosterInviteState(this.participants,this._myUserId,this.rosterLoaded)
3577
+ const mayInvite = inviteState === 'allowed'
3533
3578
  const names = incoming.map(token => `@${token}`).join(', ')
3534
3579
 
3535
3580
  return html`
3536
3581
  <div class="invite-notice">
3537
3582
  <md-icon>person_add</md-icon>
3538
3583
  <span>
3539
- ${mayInvite
3584
+ ${inviteState === 'checking' ? i18next.t('ai-assistant.text.invite-checking') : mayInvite
3540
3585
  ? i18next.t('ai-assistant.text.will-be-invited', {
3541
3586
  names,
3542
3587
  defaultValue: '{names} will join this conversation when you send'
@@ -3654,7 +3699,7 @@ export class OxAssistantChat extends LitElement {
3654
3699
  this.setComposerWide(false)
3655
3700
  } else if (action === 'send') {
3656
3701
  e.preventDefault()
3657
- if (!this.busy) this.send()
3702
+ if (!this.busy) void this.submitMessage()
3658
3703
  }
3659
3704
  }
3660
3705
 
@@ -4074,12 +4119,12 @@ export class OxAssistantChat extends LitElement {
4074
4119
  <md-icon>group</md-icon>${online}/${this.participants.length}
4075
4120
  </span>
4076
4121
  ${this.participants.map(p => {
4077
- const name = p.user?.name || p.user?.id || '?'
4122
+ const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
4078
4123
  const on = this._isOnline(p.lastSeenAt)
4079
4124
  return html`<span
4080
4125
  class="roster-chip ${on ? 'online' : 'offline'}"
4081
4126
  title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
4082
- <span class="roster-dot"></span>${initials(name)}
4127
+ <span class="roster-dot"></span><span class="roster-name">${name}</span>
4083
4128
  </span>`
4084
4129
  })}
4085
4130
  </div>
@@ -4231,7 +4276,7 @@ export class OxAssistantChat extends LitElement {
4231
4276
  * system : 없음
4232
4277
  */
4233
4278
  private renderMessageActions(idx: number, line: ChatLine, content: string) {
4234
- if (line.role !== 'assistant') return nothing
4279
+ if (line.role === 'system') return nothing
4235
4280
 
4236
4281
  const isCopied = this.copiedIdx === idx
4237
4282
  // revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
@@ -4245,13 +4290,17 @@ export class OxAssistantChat extends LitElement {
4245
4290
  @click=${() => this.copyMessage(idx, content)}>
4246
4291
  <md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
4247
4292
  </button>
4248
- <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
4249
4298
  class="msg-action"
4250
4299
  ?disabled=${this.busy}
4251
4300
  title=${i18next.t('ai-assistant.button.regenerate')}
4252
4301
  @click=${() => this.regenerateAssistant(idx)}>
4253
4302
  <md-icon>refresh</md-icon>
4254
- </button>
4303
+ </button>` : nothing}
4255
4304
  </div>
4256
4305
  `
4257
4306
  }
@@ -5050,7 +5099,8 @@ export class OxAssistantChat extends LitElement {
5050
5099
  if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
5051
5100
  }
5052
5101
  let enrichedContent = injectMentionRefids(promptText, mentions)
5053
- enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
5102
+ enrichedContent = this.replyContent(injectMentionUserIds(enrichedContent, userMentions))
5103
+ this.replyingTo = undefined
5054
5104
 
5055
5105
  // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
5056
5106
  const userLocalId = `local-${++this._localSeq}-u`
@@ -5069,7 +5119,7 @@ export class OxAssistantChat extends LitElement {
5069
5119
  base64: a.base64
5070
5120
  })),
5071
5121
  _localId: userLocalId,
5072
- senderEmail: this._myEmail
5122
+ senderId: this._myUserId, senderEmail: this._myEmail
5073
5123
  }
5074
5124
  ]
5075
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
+ }
@@ -36,3 +36,25 @@ describe('navigation wiring', () => {
36
36
  expect(dock).toContain("errorCode: 'conversation-screen-changed'")
37
37
  })
38
38
  })
39
+
40
+
41
+ describe('a carried draft survives opening a new conversation', () => {
42
+ test('automatic review starts after cleanup and is sent once per request', () => {
43
+ const source = readFileSync(join(__dirname, '../components/assistant-chat.ts'), 'utf8')
44
+ const update = source.slice(source.indexOf(' updated(changed:'), source.indexOf(' disconnectedCallback()', source.indexOf(' updated(changed:')))
45
+ const placement = update.indexOf('if (this.autoAsk &&')
46
+ expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('sessionId')"))
47
+ expect(update.slice(placement)).not.toContain("this.input = ''")
48
+ expect(update.slice(placement)).toContain('!== this._autoAsked')
49
+ expect(update.slice(placement)).toContain('void this.send()')
50
+ })
51
+ test('draft placement follows subject, mode and session cleanup', () => {
52
+ const source = readFileSync(join(__dirname, '../components/assistant-chat.ts'), 'utf8')
53
+ const update = source.slice(source.indexOf(' updated(changed:'), source.indexOf(' disconnectedCallback()', source.indexOf(' updated(changed:')))
54
+ const placement = update.indexOf('const draft = assistantDraftDecision(')
55
+ expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('subject'))"))
56
+ expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('mode')"))
57
+ expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('sessionId')"))
58
+ expect(update.slice(placement)).not.toContain("this.input = ''")
59
+ })
60
+ })
@@ -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 ④).