@things-factory/board-ai 10.0.0-beta.95 → 10.0.0-y.0

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 (86) hide show
  1. package/client/components/board-ai-chat.ts +326 -25
  2. package/client/components/chat-echo-dedup.test.ts +116 -0
  3. package/client/components/chat-echo-dedup.ts +89 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +36 -0
  5. package/dist-client/client/components/board-ai-chat.js +334 -44
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.d.ts +45 -0
  8. package/dist-client/client/components/chat-echo-dedup.js +58 -0
  9. package/dist-client/client/components/chat-echo-dedup.js.map +1 -0
  10. package/dist-client/client/components/chat-echo-dedup.test.d.ts +1 -0
  11. package/dist-client/client/components/chat-echo-dedup.test.js +101 -0
  12. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -0
  13. package/dist-client/tsconfig.tsbuildinfo +1 -1
  14. package/dist-server/service/board-ai-resolver.d.ts +8 -0
  15. package/dist-server/service/board-ai-resolver.js +70 -0
  16. package/dist-server/service/board-ai-resolver.js.map +1 -1
  17. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  18. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  19. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  20. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  21. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  22. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  23. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  24. package/dist-server/service/chat-message/chat-message-subscription.js +53 -0
  25. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  26. package/dist-server/service/chat-message/index.d.ts +2 -0
  27. package/dist-server/service/chat-message/index.js +6 -1
  28. package/dist-server/service/chat-message/index.js.map +1 -1
  29. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  30. package/dist-server/service/chat-session-participant/index.js +4 -1
  31. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  32. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  33. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  34. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  35. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  36. package/dist-server/service/chat-session-participant/presence.js +22 -0
  37. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  38. package/dist-server/service/chat-session-resolver.d.ts +1 -0
  39. package/dist-server/service/chat-session-resolver.js +31 -1
  40. package/dist-server/service/chat-session-resolver.js.map +1 -1
  41. package/dist-server/service/index.d.ts +3 -1
  42. package/dist-server/service/index.js +3 -1
  43. package/dist-server/service/index.js.map +1 -1
  44. package/dist-server/service/mention-marker.d.ts +11 -0
  45. package/dist-server/service/mention-marker.js +41 -0
  46. package/dist-server/service/mention-marker.js.map +1 -1
  47. package/dist-server/service/patch-entry/board-patch-broadcast.d.ts +13 -0
  48. package/dist-server/service/patch-entry/board-patch-broadcast.js +13 -0
  49. package/dist-server/service/patch-entry/board-patch-broadcast.js.map +1 -0
  50. package/dist-server/service/patch-entry/board-patch-subscription.d.ts +12 -0
  51. package/dist-server/service/patch-entry/board-patch-subscription.js +63 -0
  52. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -0
  53. package/dist-server/service/patch-entry/index.d.ts +1 -0
  54. package/dist-server/service/patch-entry/index.js +6 -1
  55. package/dist-server/service/patch-entry/index.js.map +1 -1
  56. package/dist-server/service/subscribe-auth.d.ts +14 -0
  57. package/dist-server/service/subscribe-auth.js +33 -0
  58. package/dist-server/service/subscribe-auth.js.map +1 -0
  59. package/dist-server/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +5 -5
  61. package/server/service/assistant.test.ts +5 -1
  62. package/server/service/board-ai-resolver.ts +87 -1
  63. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  64. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  65. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  66. package/server/service/chat-message/chat-message-publish.ts +22 -0
  67. package/server/service/chat-message/chat-message-subscription.ts +41 -0
  68. package/server/service/chat-message/index.ts +7 -0
  69. package/server/service/chat-session-participant/index.ts +1 -0
  70. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  71. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  72. package/server/service/chat-session-participant/presence.test.ts +39 -0
  73. package/server/service/chat-session-participant/presence.ts +21 -0
  74. package/server/service/chat-session-resolver.ts +25 -1
  75. package/server/service/index.ts +3 -1
  76. package/server/service/mention-marker.extract.test.ts +61 -0
  77. package/server/service/mention-marker.ts +42 -0
  78. package/server/service/patch-entry/board-patch-broadcast.ts +24 -0
  79. package/server/service/patch-entry/board-patch-subscription.test.ts +40 -0
  80. package/server/service/patch-entry/board-patch-subscription.ts +50 -0
  81. package/server/service/patch-entry/index.ts +7 -0
  82. package/server/service/styling/tier1-tools.test.ts +2 -1
  83. package/server/service/subscribe-auth.test.ts +57 -0
  84. package/server/service/subscribe-auth.ts +34 -0
  85. package/translations/en.json +1 -0
  86. package/translations/ko.json +1 -0
@@ -19,6 +19,7 @@ import { LitElement, css, html, nothing } from 'lit'
19
19
  import { customElement, property, state } from 'lit/decorators.js'
20
20
  import { unsafeHTML } from 'lit/directives/unsafe-html.js'
21
21
  import { client } from '@operato/graphql'
22
+ import { auth } from '@things-factory/auth-base/dist-client/index.js'
22
23
  import { i18next, localize } from '@operato/i18n'
23
24
  import { ScrollbarStyles } from '@operato/styles'
24
25
  import gql from 'graphql-tag'
@@ -30,6 +31,7 @@ import {
30
31
  stripMentionRefids
31
32
  } from './markdown.js'
32
33
  import { buildChatMutationInput } from './chat-input-builder.js'
34
+ import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
33
35
  import './mention-popup.js'
34
36
  import {
35
37
  buildMentionCandidates,
@@ -48,6 +50,13 @@ import { DEFAULT_SLASH_TEMPLATES } from './default-slash-templates.js'
48
50
 
49
51
  interface ChatLine {
50
52
  role: 'user' | 'assistant' | 'system'
53
+ /** 서버 ChatMessage.id — 실시간 방송 에코 dedup 의 키 (있을 때만). */
54
+ id?: string
55
+ /** 클라 임시 핸들 — 영속 id 부여 전 optimistic 라인을 index 비의존으로 식별/reconcile. */
56
+ _localId?: string
57
+ /** 보낸 사용자 id/name — 그룹챗 발신자 표시용 (다른 참여자 메시지 구분). */
58
+ senderId?: string
59
+ senderName?: string
51
60
  /** 메시지 본문. `#token{refid:N}` inline marker 가 포함될 수 있음 (영속된 메시지 / 사용자
52
61
  * popup pick / AI resolved mention). 렌더링 시 추출해서 `<span data-refid="N">` 으로 변환. */
53
62
  content: string
@@ -87,10 +96,65 @@ const CHAT_MESSAGES_QUERY = gql`
87
96
  relatedPatchId
88
97
  toolUsagesJson
89
98
  createdAt
99
+ creator {
100
+ id
101
+ name
102
+ }
103
+ }
104
+ }
105
+ `
106
+
107
+ // C4 그룹챗 — 같은 세션의 메시지(사용자/AI/system) 실시간 방송 구독.
108
+ // 자기 발신 에코는 message.id 로 dedup (mutation 응답으로 이미 렌더).
109
+ const CHAT_MESSAGE_SUBSCRIPTION = gql`
110
+ subscription ChatMessageActivity($sessionId: String!) {
111
+ chatMessageActivity(sessionId: $sessionId) {
112
+ id
113
+ role
114
+ content
115
+ relatedPatchId
116
+ toolUsagesJson
117
+ createdAt
118
+ creator {
119
+ id
120
+ name
121
+ }
90
122
  }
91
123
  }
92
124
  `
93
125
 
126
+ // C4 presence/roster — 세션 참여자 목록 + 마지막 접속(online 판정용).
127
+ const CHAT_PARTICIPANTS_QUERY = gql`
128
+ query ChatSessionParticipants($sessionId: String!) {
129
+ chatSessionParticipants(sessionId: $sessionId) {
130
+ id
131
+ role
132
+ lastSeenAt
133
+ user {
134
+ id
135
+ name
136
+ }
137
+ }
138
+ }
139
+ `
140
+ const TOUCH_PRESENCE_MUTATION = gql`
141
+ mutation TouchPresence($sessionId: String!) {
142
+ touchPresence(sessionId: $sessionId)
143
+ }
144
+ `
145
+ // heartbeat 주기(ms) — 서버 PRESENCE_TTL_MS(75s)의 ~1/2.5. 로컬 online 판정도 동일 TTL.
146
+ const PRESENCE_HEARTBEAT_MS = 30_000
147
+ const PRESENCE_TTL_MS = 75_000
148
+
149
+ /** 이름 → roster chip 이니셜 (한글 1자 / 영문 최대 2자). */
150
+ function initials(name: string): string {
151
+ const n = (name || '?').trim()
152
+ if (!n) return '?'
153
+ const parts = n.split(/\s+/)
154
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
155
+ return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
156
+ }
157
+
94
158
  @customElement('ox-board-ai-chat')
95
159
  export class OxBoardAIChat extends LitElement {
96
160
  /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
@@ -221,6 +285,28 @@ export class OxBoardAIChat extends LitElement {
221
285
  @state()
222
286
  private errorMessage?: string
223
287
 
288
+ /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
289
+ @state()
290
+ private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string } }> = []
291
+
292
+ /** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
293
+ @state()
294
+ private _presenceTick = 0
295
+
296
+ private _heartbeatTimer?: ReturnType<typeof setInterval>
297
+
298
+ /** C4 그룹챗 — 메시지 실시간 방송 구독 핸들 + 에코 dedup. */
299
+ private _messageSub?: { unsubscribe: () => void }
300
+ /** 이미 transcript 에 반영된 서버 메시지 id 집합 — 자기 에코·중복 방송 차단. */
301
+ private _seenMessageIds = new Set<string>()
302
+ /** optimistic 라인 임시 핸들 시퀀스 (index 비의존 reconcile 용). */
303
+ private _localSeq = 0
304
+
305
+ /** 현재 로그인 사용자 id — 그룹챗에서 내 메시지 vs 타인 메시지 구분 (발신자 표시). */
306
+ private get _myUserId(): string | undefined {
307
+ return (auth.credential as any)?.id
308
+ }
309
+
224
310
  /** Mention popup — `#` (컴포넌트) / `@` (카탈로그) / `/` (slash) 트리거 시 활성.
225
311
  * 단일 popup 재사용 — mentionTrigger 로 활성 trigger 추적. */
226
312
  @state()
@@ -307,6 +393,50 @@ export class OxBoardAIChat extends LitElement {
307
393
  background: var(--md-sys-color-surface, #ffffff);
308
394
  }
309
395
 
396
+ /* ── C4 Roster (participants + presence) ───────────── */
397
+ .roster {
398
+ display: flex;
399
+ align-items: center;
400
+ flex-wrap: wrap;
401
+ gap: 4px;
402
+ padding: 4px 8px;
403
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
404
+ }
405
+ .roster-count {
406
+ display: inline-flex;
407
+ align-items: center;
408
+ gap: 2px;
409
+ font-size: 10px;
410
+ opacity: 0.7;
411
+ margin-right: 2px;
412
+ }
413
+ .roster-count md-icon {
414
+ font-size: 13px;
415
+ width: 13px;
416
+ height: 13px;
417
+ }
418
+ .roster-chip {
419
+ display: inline-flex;
420
+ align-items: center;
421
+ gap: 3px;
422
+ padding: 1px 6px 1px 4px;
423
+ border-radius: 9px;
424
+ font-size: 10px;
425
+ background: var(--md-sys-color-surface-variant, #f1f5f9);
426
+ }
427
+ .roster-chip.offline {
428
+ opacity: 0.45;
429
+ }
430
+ .roster-dot {
431
+ width: 6px;
432
+ height: 6px;
433
+ border-radius: 50%;
434
+ background: #cbd5e1;
435
+ }
436
+ .roster-chip.online .roster-dot {
437
+ background: #22c55e;
438
+ }
439
+
310
440
  /* ── Session tabs ─────────────────────────────────── */
311
441
  .session-tabs {
312
442
  display: flex;
@@ -616,6 +746,24 @@ export class OxBoardAIChat extends LitElement {
616
746
  /* margin-top 0 — .messages gap 으로만 spacing */
617
747
  }
618
748
 
749
+ /* 그룹챗 — 다른 참여자의 메시지. 내 메시지(우측 primary)와 구분: 좌측 정렬 + 보조 색. */
750
+ .msg-wrap.user.other {
751
+ align-self: flex-start;
752
+ align-items: flex-start;
753
+ }
754
+ .msg.user.other {
755
+ align-self: flex-start;
756
+ background: var(--md-sys-color-secondary-container, #e2e8f0);
757
+ color: var(--md-sys-color-on-secondary-container, #0f172a);
758
+ border-radius: 12px 12px 12px 3px;
759
+ }
760
+ /* 발신자 이름 라벨 — AI 라벨(대문자)과 달리 사람 이름은 그대로. */
761
+ .msg-label.sender {
762
+ text-transform: none;
763
+ letter-spacing: 0;
764
+ color: var(--md-sys-color-primary, #475569);
765
+ }
766
+
619
767
  .msg.assistant {
620
768
  align-self: flex-start;
621
769
  background: var(--md-sys-color-surface-container, #f8fafc);
@@ -1293,8 +1441,15 @@ export class OxBoardAIChat extends LitElement {
1293
1441
  this.cachedUserCandidates = []
1294
1442
  this.errorMessage = undefined
1295
1443
  this.input = ''
1444
+ this.participants = []
1445
+ this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
1296
1446
  if (this.sessionId) {
1297
1447
  this.loadHistory()
1448
+ this._startPresence() // C4: roster 로드 + heartbeat 시작
1449
+ this._startMessageSubscription() // C4: 메시지 실시간 방송 구독
1450
+ } else {
1451
+ this._stopPresence()
1452
+ this._stopMessageSubscription()
1298
1453
  }
1299
1454
  }
1300
1455
  // 자동 스크롤
@@ -1302,6 +1457,119 @@ export class OxBoardAIChat extends LitElement {
1302
1457
  if (ms) ms.scrollTop = ms.scrollHeight
1303
1458
  }
1304
1459
 
1460
+ disconnectedCallback() {
1461
+ this._stopPresence()
1462
+ this._stopMessageSubscription()
1463
+ super.disconnectedCallback()
1464
+ }
1465
+
1466
+ // ── C4 presence/roster ─────────────────────────────────────────────────────
1467
+ private _startPresence() {
1468
+ this._stopPresence()
1469
+ this._loadParticipants()
1470
+ this._touchPresence() // 즉시 1회 — 내 online 즉시 반영
1471
+ this._heartbeatTimer = setInterval(() => {
1472
+ this._touchPresence()
1473
+ this._loadParticipants()
1474
+ this._presenceTick++ // online 판정 재평가(시간 경과)
1475
+ }, PRESENCE_HEARTBEAT_MS)
1476
+ }
1477
+
1478
+ private _stopPresence() {
1479
+ if (this._heartbeatTimer) {
1480
+ clearInterval(this._heartbeatTimer)
1481
+ this._heartbeatTimer = undefined
1482
+ }
1483
+ }
1484
+
1485
+ // ── C4 그룹챗 — 메시지 실시간 방송 구독 ─────────────────────────────────────
1486
+ private _startMessageSubscription() {
1487
+ this._stopMessageSubscription()
1488
+ if (!this.sessionId) return
1489
+ try {
1490
+ this._messageSub = client
1491
+ .subscribe({ query: CHAT_MESSAGE_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
1492
+ .subscribe({
1493
+ next: ({ data }: any) => this._onRemoteMessage(data?.chatMessageActivity),
1494
+ error: () => {
1495
+ /* 구독 에러는 채팅 본 기능 무영향 — 히스토리 쿼리로 폴백 */
1496
+ }
1497
+ })
1498
+ } catch {
1499
+ /* 구독 미지원 환경 — 조용히 스킵 */
1500
+ }
1501
+ }
1502
+
1503
+ private _stopMessageSubscription() {
1504
+ if (this._messageSub) {
1505
+ try {
1506
+ this._messageSub.unsubscribe()
1507
+ } catch {
1508
+ /* 이미 닫힘 */
1509
+ }
1510
+ this._messageSub = undefined
1511
+ }
1512
+ }
1513
+
1514
+ /** 서버 ChatMessage → ChatLine. */
1515
+ private _msgToLine(m: any): ChatLine {
1516
+ return remoteMessageToLine(m) as ChatLine
1517
+ }
1518
+
1519
+ /**
1520
+ * 원격(방송) 메시지 수신 — 에코·중복은 id 로 차단, 신규는 transcript 에 append.
1521
+ * 자기 발신 메시지의 id 는 전송 reconcile 에서 이미 _seenMessageIds 에 등록됨.
1522
+ */
1523
+ private _onRemoteMessage(m: any) {
1524
+ const next = receiveRemoteMessage(this.lines as any, this._seenMessageIds, m)
1525
+ if (!next) return
1526
+ this._seenMessageIds = next.seen
1527
+ this.lines = next.lines as ChatLine[]
1528
+ }
1529
+
1530
+ /**
1531
+ * 전송한 optimistic 라인을 서버 id 로 reconcile (index 비의존 — _localId 로 탐색).
1532
+ * 에코가 응답보다 먼저 도착해 이미 append 됐으면 optimistic 중복 라인을 제거한다.
1533
+ */
1534
+ private _reconcileSentLine(localId: string, serverId: string | undefined, patch: Partial<ChatLine>) {
1535
+ const next = reconcileSentLine(this.lines as any, this._seenMessageIds, localId, serverId, patch as any)
1536
+ this._seenMessageIds = next.seen
1537
+ this.lines = next.lines as ChatLine[]
1538
+ }
1539
+
1540
+ private async _loadParticipants() {
1541
+ if (!this.sessionId) return
1542
+ try {
1543
+ const result = await client.query({
1544
+ query: CHAT_PARTICIPANTS_QUERY,
1545
+ variables: { sessionId: this.sessionId },
1546
+ fetchPolicy: 'network-only'
1547
+ })
1548
+ this.participants = result.data?.chatSessionParticipants ?? []
1549
+ } catch {
1550
+ /* roster 로드 실패는 채팅 본 기능 무영향 — 조용히 무시 */
1551
+ }
1552
+ }
1553
+
1554
+ private async _touchPresence() {
1555
+ if (!this.sessionId) return
1556
+ try {
1557
+ await client.mutate({ mutation: TOUCH_PRESENCE_MUTATION, variables: { sessionId: this.sessionId } })
1558
+ } catch {
1559
+ /* heartbeat 실패 무시 */
1560
+ }
1561
+ }
1562
+
1563
+ /** lastSeenAt 이 TTL 이내면 online. (_presenceTick 의존으로 주기 재평가.) */
1564
+ private _isOnline(lastSeenAt?: string): boolean {
1565
+ void this._presenceTick
1566
+ if (!lastSeenAt) return false
1567
+ const t = new Date(lastSeenAt).getTime()
1568
+ if (!Number.isFinite(t)) return false
1569
+ const age = Date.now() - t
1570
+ return age >= 0 ? age <= PRESENCE_TTL_MS : true
1571
+ }
1572
+
1305
1573
  private async loadHistory() {
1306
1574
  if (!this.sessionId) return
1307
1575
  try {
@@ -1311,12 +1579,9 @@ export class OxBoardAIChat extends LitElement {
1311
1579
  fetchPolicy: 'network-only'
1312
1580
  })
1313
1581
  const msgs = result.data?.chatMessages ?? []
1314
- this.lines = msgs.map((m: any) => ({
1315
- role: m.role,
1316
- content: m.content,
1317
- patchId: m.relatedPatchId ?? undefined,
1318
- toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined
1319
- }))
1582
+ this.lines = msgs.map((m: any) => this._msgToLine(m))
1583
+ // 로드된 메시지 id 를 seen 에 등록 — 이후 방송 에코 dedup 기준.
1584
+ this._seenMessageIds = new Set<string>(msgs.map((m: any) => m.id).filter(Boolean))
1320
1585
  } catch (e: any) {
1321
1586
  this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`
1322
1587
  }
@@ -1329,20 +1594,29 @@ export class OxBoardAIChat extends LitElement {
1329
1594
  ? html`<div class="toast show">${this.toastMessage}</div>`
1330
1595
  : nothing}
1331
1596
  ${this.renderSessionTabs()}
1597
+ ${this.renderRoster()}
1332
1598
  <div class="messages" @click=${this.onBubbleClick}>
1333
1599
  ${!hasMessages ? this.renderEmpty() : nothing}
1334
- ${this.lines.map((line, idx) => {
1600
+ ${(() => {
1601
+ const myId = this._myUserId
1602
+ return this.lines.map((line, idx) => {
1335
1603
  const prev = this.lines[idx - 1]
1336
1604
  const showLabel = line.role === 'assistant' && (!prev || prev.role !== 'assistant')
1605
+ // 그룹챗: 다른 참여자의 user 메시지는 좌측 + 보낸이 이름 표시(연속 동일 발신자는 1회만).
1606
+ const isOther = line.role === 'user' && !!line.senderId && line.senderId !== myId
1607
+ const prevSameSender = !!prev && prev.role === 'user' && prev.senderId === line.senderId
1608
+ const showSenderLabel = isOther && !prevSameSender
1337
1609
  const content = (line.content ?? '').trim()
1338
1610
  const isMarkdown = line.role !== 'system' && content.length > 0
1339
1611
  const showActions = line.role !== 'system' && !line.pending && content.length > 0
1340
1612
  return html`
1341
1613
  ${showLabel
1342
1614
  ? html`<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
1343
- : nothing}
1344
- <div class="msg-wrap ${line.role}">
1345
- <div class="msg ${line.role} ${line.pending ? 'pending' : ''}">
1615
+ : showSenderLabel
1616
+ ? html`<div class="msg-label sender">${line.senderName || i18next.t('board-ai.label.participant')}</div>`
1617
+ : nothing}
1618
+ <div class="msg-wrap ${line.role} ${isOther ? 'other' : ''}">
1619
+ <div class="msg ${line.role} ${isOther ? 'other' : ''} ${line.pending ? 'pending' : ''}">
1346
1620
  ${line.pending
1347
1621
  ? html`<span class="typing-dots"><span></span><span></span><span></span></span>`
1348
1622
  : isMarkdown
@@ -1376,7 +1650,8 @@ export class OxBoardAIChat extends LitElement {
1376
1650
  ${showActions ? this.renderMessageActions(idx, line, content) : nothing}
1377
1651
  </div>
1378
1652
  `
1379
- })}
1653
+ })
1654
+ })()}
1380
1655
  </div>
1381
1656
  ${this.errorMessage
1382
1657
  ? html`
@@ -1828,6 +2103,28 @@ export class OxBoardAIChat extends LitElement {
1828
2103
  `
1829
2104
  }
1830
2105
 
2106
+ /** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
2107
+ private renderRoster() {
2108
+ if (!this.sessionId || this.participants.length < 2) return nothing
2109
+ const online = this.participants.filter(p => this._isOnline(p.lastSeenAt)).length
2110
+ return html`
2111
+ <div class="roster" role="group" aria-label="participants">
2112
+ <span class="roster-count" title=${`${online} online / ${this.participants.length} total`}>
2113
+ <md-icon>group</md-icon>${online}/${this.participants.length}
2114
+ </span>
2115
+ ${this.participants.map(p => {
2116
+ const name = p.user?.name || p.user?.id || '?'
2117
+ const on = this._isOnline(p.lastSeenAt)
2118
+ return html`<span
2119
+ class="roster-chip ${on ? 'online' : 'offline'}"
2120
+ title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
2121
+ <span class="roster-dot"></span>${initials(name)}
2122
+ </span>`
2123
+ })}
2124
+ </div>
2125
+ `
2126
+ }
2127
+
1831
2128
  private _onSessionTabClick(sessionId: string) {
1832
2129
  if (sessionId === this.sessionId) return
1833
2130
  this.dispatchEvent(
@@ -2160,23 +2457,26 @@ export class OxBoardAIChat extends LitElement {
2160
2457
  let enrichedContent = injectMentionRefids(text, mentions)
2161
2458
  enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
2162
2459
 
2460
+ // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
2461
+ const userLocalId = `local-${++this._localSeq}-u`
2462
+ const asstLocalId = `local-${++this._localSeq}-a`
2463
+
2163
2464
  this.lines = [
2164
2465
  ...this.lines,
2165
- { role: 'user', content: enrichedContent }
2466
+ { role: 'user', content: enrichedContent, _localId: userLocalId }
2166
2467
  ]
2167
2468
  this.input = ''
2168
2469
  this.busy = true
2169
2470
  this.errorMessage = undefined
2170
2471
 
2171
2472
  // 가짜 pending 응답 표시 (UX 부드럽게)
2172
- const pendingIdx = this.lines.length
2173
- this.lines = [...this.lines, { role: 'assistant', content: '', pending: true }]
2473
+ this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
2174
2474
 
2175
2475
  try {
2176
- // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류)
2476
+ // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
2477
+ // pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
2177
2478
  const history = this.lines
2178
- .slice(0, pendingIdx) // pending 제외
2179
- .filter(l => l.role !== 'system')
2479
+ .filter(l => !l.pending && l._localId !== asstLocalId && l.role !== 'system')
2180
2480
  .map(l => ({ role: l.role, content: l.content }))
2181
2481
 
2182
2482
  // 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
@@ -2208,17 +2508,18 @@ export class OxBoardAIChat extends LitElement {
2208
2508
  this.pickedMentions.clear()
2209
2509
  this.pickedUserMentions.clear()
2210
2510
 
2211
- // pending 자리에 실제 응답
2212
- const replaced = [...this.lines]
2213
- replaced[pendingIdx] = {
2511
+ // optimistic 라인을 서버 id 로 reconcile (방송 에코와 중복 방지, index 비의존).
2512
+ // 사용자 라인: enrich 된 본문 유지, id 만 부여. AI 라인: pending → 실제 응답.
2513
+ this._reconcileSentLine(userLocalId, out.userMessageId, {})
2514
+ this._reconcileSentLine(asstLocalId, out.assistantMessageId, {
2214
2515
  role: 'assistant',
2215
2516
  content: out.reply,
2216
2517
  patchSummary: out.patch?.summary,
2217
2518
  followUp: out.followUp || undefined,
2218
2519
  patchId: out.patchId || undefined,
2219
- toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined
2220
- }
2221
- this.lines = replaced
2520
+ toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
2521
+ pending: false
2522
+ })
2222
2523
 
2223
2524
  // 호스트로 patch 이벤트 전파
2224
2525
  if (out.patch) {
@@ -2256,8 +2557,8 @@ export class OxBoardAIChat extends LitElement {
2256
2557
  )
2257
2558
  }
2258
2559
  } catch (e: any) {
2259
- // pending 메시지 제거 + 에러 표시 + 마지막 입력 보존 (다시 시도용)
2260
- this.lines = this.lines.slice(0, pendingIdx)
2560
+ // pending(AI) placeholder 만 제거 — 사용자 라인은 보존(재시도). index 비의존.
2561
+ this.lines = this.lines.filter(l => l._localId !== asstLocalId)
2261
2562
  this.errorMessage = e?.message ?? String(e)
2262
2563
  this.lastFailedInput = text
2263
2564
  } finally {
@@ -0,0 +1,116 @@
1
+ import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'
2
+
3
+ const msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })
4
+
5
+ describe('remoteMessageToLine', () => {
6
+ it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {
7
+ const line = remoteMessageToLine({
8
+ id: 'm1',
9
+ role: 'assistant',
10
+ content: 'done',
11
+ relatedPatchId: 'p1',
12
+ toolUsagesJson: [{ name: 'x' }]
13
+ })
14
+ expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })
15
+ expect(line.toolUsages).toEqual([{ name: 'x' }])
16
+ })
17
+
18
+ it('toolUsagesJson 이 배열 아니면 undefined', () => {
19
+ expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()
20
+ })
21
+
22
+ it('creator → senderId/senderName (그룹챗 발신자 표시)', () => {
23
+ const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { id: 'u9', name: 'Kim' } })
24
+ expect(line.senderId).toBe('u9')
25
+ expect(line.senderName).toBe('Kim')
26
+ })
27
+
28
+ it('creator 없으면 sender 필드 undefined', () => {
29
+ const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })
30
+ expect(line.senderId).toBeUndefined()
31
+ expect(line.senderName).toBeUndefined()
32
+ })
33
+ })
34
+
35
+ describe('receiveRemoteMessage — 방송 수신/에코 차단', () => {
36
+ it('신규 id → append + seen 등록', () => {
37
+ const res = receiveRemoteMessage([], new Set(), msg('m1'))!
38
+ expect(res).not.toBeNull()
39
+ expect(res.lines).toHaveLength(1)
40
+ expect(res.lines[0].id).toBe('m1')
41
+ expect(res.seen.has('m1')).toBe(true)
42
+ })
43
+
44
+ it('이미 seen 인 id → null (에코·중복 차단)', () => {
45
+ expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()
46
+ })
47
+
48
+ it('id 없는 메시지 → null', () => {
49
+ expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()
50
+ expect(receiveRemoteMessage([], new Set(), null)).toBeNull()
51
+ })
52
+
53
+ it('불변 — 원본 배열/셋 미변경', () => {
54
+ const lines: DedupLine[] = []
55
+ const seen = new Set<string>()
56
+ receiveRemoteMessage(lines, seen, msg('m1'))
57
+ expect(lines).toHaveLength(0)
58
+ expect(seen.size).toBe(0)
59
+ })
60
+
61
+ it('다른 참여자 메시지 두 개 순차 append', () => {
62
+ let lines: DedupLine[] = []
63
+ let seen = new Set<string>()
64
+ ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)
65
+ ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)
66
+ expect(lines.map(l => l.id)).toEqual(['a', 'b'])
67
+ })
68
+ })
69
+
70
+ describe('reconcileSentLine — optimistic 라인 조정', () => {
71
+ const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]
72
+
73
+ it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {
74
+ const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})
75
+ expect(res.lines).toHaveLength(1)
76
+ expect(res.lines[0].id).toBe('srv1')
77
+ expect(res.lines[0]._localId).toBeUndefined()
78
+ expect(res.seen.has('srv1')).toBe(true)
79
+ })
80
+
81
+ it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {
82
+ // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨
83
+ const lines: DedupLine[] = [
84
+ { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것
85
+ { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복
86
+ ]
87
+ const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})
88
+ expect(res.lines).toHaveLength(1)
89
+ expect(res.lines[0].id).toBe('srv1')
90
+ expect(res.lines.some(l => l._localId === 'L1')).toBe(false)
91
+ })
92
+
93
+ it('patch 병합 (AI 라인: pending → 실제 응답)', () => {
94
+ const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]
95
+ const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {
96
+ content: 'reply',
97
+ pending: false,
98
+ patchId: 'p9'
99
+ })
100
+ expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })
101
+ })
102
+
103
+ it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {
104
+ const seen = new Set<string>()
105
+ const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})
106
+ expect(res.lines[0]._localId).toBeUndefined()
107
+ expect(res.lines[0].id).toBeUndefined()
108
+ expect(res.seen.size).toBe(0)
109
+ })
110
+
111
+ it('해당 _localId 없으면 변화 없음', () => {
112
+ const lines = optimistic()
113
+ const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})
114
+ expect(res.lines).toBe(lines)
115
+ })
116
+ })