@things-factory/board-ai 10.0.0-beta.108

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 (329) hide show
  1. package/PLAN-styling-tools.md +1253 -0
  2. package/client/components/board-ai-chat.test.ts +149 -0
  3. package/client/components/board-ai-chat.ts +2574 -0
  4. package/client/components/chat-echo-dedup.test.ts +116 -0
  5. package/client/components/chat-echo-dedup.ts +89 -0
  6. package/client/components/chat-input-builder.ts +47 -0
  7. package/client/components/default-catalog-entries.test.ts +59 -0
  8. package/client/components/default-catalog-entries.ts +100 -0
  9. package/client/components/default-slash-templates.test.ts +55 -0
  10. package/client/components/default-slash-templates.ts +129 -0
  11. package/client/components/markdown.test.ts +518 -0
  12. package/client/components/markdown.ts +508 -0
  13. package/client/components/mention-popup-helpers.ts +433 -0
  14. package/client/components/mention-popup.test.ts +588 -0
  15. package/client/components/mention-popup.ts +238 -0
  16. package/client/index.ts +11 -0
  17. package/client/tsconfig.json +13 -0
  18. package/client/utils/board-edit-patch.ts +374 -0
  19. package/config/config.development.js +43 -0
  20. package/config/config.production.js +15 -0
  21. package/dist-client/client/components/board-ai-chat.d.ts +277 -0
  22. package/dist-client/client/components/board-ai-chat.js +2444 -0
  23. package/dist-client/client/components/board-ai-chat.js.map +1 -0
  24. package/dist-client/client/components/board-ai-chat.test.d.ts +1 -0
  25. package/dist-client/client/components/board-ai-chat.test.js +138 -0
  26. package/dist-client/client/components/board-ai-chat.test.js.map +1 -0
  27. package/dist-client/client/components/chat-echo-dedup.d.ts +45 -0
  28. package/dist-client/client/components/chat-echo-dedup.js +58 -0
  29. package/dist-client/client/components/chat-echo-dedup.js.map +1 -0
  30. package/dist-client/client/components/chat-echo-dedup.test.d.ts +1 -0
  31. package/dist-client/client/components/chat-echo-dedup.test.js +101 -0
  32. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -0
  33. package/dist-client/client/components/chat-input-builder.d.ts +39 -0
  34. package/dist-client/client/components/chat-input-builder.js +26 -0
  35. package/dist-client/client/components/chat-input-builder.js.map +1 -0
  36. package/dist-client/client/components/default-catalog-entries.d.ts +15 -0
  37. package/dist-client/client/components/default-catalog-entries.js +86 -0
  38. package/dist-client/client/components/default-catalog-entries.js.map +1 -0
  39. package/dist-client/client/components/default-catalog-entries.test.d.ts +1 -0
  40. package/dist-client/client/components/default-catalog-entries.test.js +52 -0
  41. package/dist-client/client/components/default-catalog-entries.test.js.map +1 -0
  42. package/dist-client/client/components/default-slash-templates.d.ts +17 -0
  43. package/dist-client/client/components/default-slash-templates.js +113 -0
  44. package/dist-client/client/components/default-slash-templates.js.map +1 -0
  45. package/dist-client/client/components/default-slash-templates.test.d.ts +1 -0
  46. package/dist-client/client/components/default-slash-templates.test.js +51 -0
  47. package/dist-client/client/components/default-slash-templates.test.js.map +1 -0
  48. package/dist-client/client/components/markdown.d.ts +60 -0
  49. package/dist-client/client/components/markdown.js +422 -0
  50. package/dist-client/client/components/markdown.js.map +1 -0
  51. package/dist-client/client/components/markdown.test.d.ts +1 -0
  52. package/dist-client/client/components/markdown.test.js +418 -0
  53. package/dist-client/client/components/markdown.test.js.map +1 -0
  54. package/dist-client/client/components/mention-popup-helpers.d.ts +165 -0
  55. package/dist-client/client/components/mention-popup-helpers.js +330 -0
  56. package/dist-client/client/components/mention-popup-helpers.js.map +1 -0
  57. package/dist-client/client/components/mention-popup.d.ts +35 -0
  58. package/dist-client/client/components/mention-popup.js +223 -0
  59. package/dist-client/client/components/mention-popup.js.map +1 -0
  60. package/dist-client/client/components/mention-popup.test.d.ts +1 -0
  61. package/dist-client/client/components/mention-popup.test.js +474 -0
  62. package/dist-client/client/components/mention-popup.test.js.map +1 -0
  63. package/dist-client/client/index.d.ts +11 -0
  64. package/dist-client/client/index.js +12 -0
  65. package/dist-client/client/index.js.map +1 -0
  66. package/dist-client/client/utils/board-edit-patch.d.ts +157 -0
  67. package/dist-client/client/utils/board-edit-patch.js +284 -0
  68. package/dist-client/client/utils/board-edit-patch.js.map +1 -0
  69. package/dist-client/server/service/agentic-loop.d.ts +87 -0
  70. package/dist-client/server/service/agentic-loop.js +276 -0
  71. package/dist-client/server/service/agentic-loop.js.map +1 -0
  72. package/dist-client/server/service/assistant.d.ts +84 -0
  73. package/dist-client/server/service/assistant.js +2220 -0
  74. package/dist-client/server/service/assistant.js.map +1 -0
  75. package/dist-client/server/service/catalog-resolver.d.ts +18 -0
  76. package/dist-client/server/service/catalog-resolver.js +93 -0
  77. package/dist-client/server/service/catalog-resolver.js.map +1 -0
  78. package/dist-client/server/service/component-tree.d.ts +68 -0
  79. package/dist-client/server/service/component-tree.js +68 -0
  80. package/dist-client/server/service/component-tree.js.map +1 -0
  81. package/dist-client/server/service/mention-resolver.d.ts +96 -0
  82. package/dist-client/server/service/mention-resolver.js +172 -0
  83. package/dist-client/server/service/mention-resolver.js.map +1 -0
  84. package/dist-client/server/service/prefab-registry.d.ts +73 -0
  85. package/dist-client/server/service/prefab-registry.js +252 -0
  86. package/dist-client/server/service/prefab-registry.js.map +1 -0
  87. package/dist-client/server/service/styling/clear-tools.d.ts +15 -0
  88. package/dist-client/server/service/styling/clear-tools.js +50 -0
  89. package/dist-client/server/service/styling/clear-tools.js.map +1 -0
  90. package/dist-client/server/service/styling/color-utils.d.ts +43 -0
  91. package/dist-client/server/service/styling/color-utils.js +205 -0
  92. package/dist-client/server/service/styling/color-utils.js.map +1 -0
  93. package/dist-client/server/service/styling/copy-tools.d.ts +27 -0
  94. package/dist-client/server/service/styling/copy-tools.js +78 -0
  95. package/dist-client/server/service/styling/copy-tools.js.map +1 -0
  96. package/dist-client/server/service/styling/effect-tools.d.ts +45 -0
  97. package/dist-client/server/service/styling/effect-tools.js +85 -0
  98. package/dist-client/server/service/styling/effect-tools.js.map +1 -0
  99. package/dist-client/server/service/styling/fill-tools.d.ts +60 -0
  100. package/dist-client/server/service/styling/fill-tools.js +226 -0
  101. package/dist-client/server/service/styling/fill-tools.js.map +1 -0
  102. package/dist-client/server/service/styling/material-tools.d.ts +32 -0
  103. package/dist-client/server/service/styling/material-tools.js +88 -0
  104. package/dist-client/server/service/styling/material-tools.js.map +1 -0
  105. package/dist-client/server/service/styling/read-tools.d.ts +34 -0
  106. package/dist-client/server/service/styling/read-tools.js +42 -0
  107. package/dist-client/server/service/styling/read-tools.js.map +1 -0
  108. package/dist-client/server/service/styling/registry.d.ts +91 -0
  109. package/dist-client/server/service/styling/registry.js +345 -0
  110. package/dist-client/server/service/styling/registry.js.map +1 -0
  111. package/dist-client/server/service/styling/stroke-tools.d.ts +35 -0
  112. package/dist-client/server/service/styling/stroke-tools.js +94 -0
  113. package/dist-client/server/service/styling/stroke-tools.js.map +1 -0
  114. package/dist-client/server/service/styling/text-tools.d.ts +32 -0
  115. package/dist-client/server/service/styling/text-tools.js +101 -0
  116. package/dist-client/server/service/styling/text-tools.js.map +1 -0
  117. package/dist-client/server/service/types.d.ts +265 -0
  118. package/dist-client/server/service/types.js +2 -0
  119. package/dist-client/server/service/types.js.map +1 -0
  120. package/dist-client/server/service/validation/board-model-schema.d.ts +8311 -0
  121. package/dist-client/server/service/validation/board-model-schema.js +402 -0
  122. package/dist-client/server/service/validation/board-model-schema.js.map +1 -0
  123. package/dist-client/server/service/validation/tool-validation.d.ts +31 -0
  124. package/dist-client/server/service/validation/tool-validation.js +193 -0
  125. package/dist-client/server/service/validation/tool-validation.js.map +1 -0
  126. package/dist-client/tsconfig.tsbuildinfo +1 -0
  127. package/dist-server/index.d.ts +23 -0
  128. package/dist-server/index.js +27 -0
  129. package/dist-server/index.js.map +1 -0
  130. package/dist-server/service/agentic-loop.d.ts +87 -0
  131. package/dist-server/service/agentic-loop.js +280 -0
  132. package/dist-server/service/agentic-loop.js.map +1 -0
  133. package/dist-server/service/apply-patch.d.ts +47 -0
  134. package/dist-server/service/apply-patch.js +369 -0
  135. package/dist-server/service/apply-patch.js.map +1 -0
  136. package/dist-server/service/assistant.d.ts +84 -0
  137. package/dist-server/service/assistant.js +2237 -0
  138. package/dist-server/service/assistant.js.map +1 -0
  139. package/dist-server/service/board-ai-resolver.d.ts +54 -0
  140. package/dist-server/service/board-ai-resolver.js +409 -0
  141. package/dist-server/service/board-ai-resolver.js.map +1 -0
  142. package/dist-server/service/catalog-resolver.d.ts +18 -0
  143. package/dist-server/service/catalog-resolver.js +98 -0
  144. package/dist-server/service/catalog-resolver.js.map +1 -0
  145. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  146. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  147. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  148. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  149. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  150. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  151. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  152. package/dist-server/service/chat-message/chat-message-subscription.js +53 -0
  153. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  154. package/dist-server/service/chat-message/chat-message.d.ts +56 -0
  155. package/dist-server/service/chat-message/chat-message.js +149 -0
  156. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  157. package/dist-server/service/chat-message/index.d.ts +5 -0
  158. package/dist-server/service/chat-message/index.js +12 -0
  159. package/dist-server/service/chat-message/index.js.map +1 -0
  160. package/dist-server/service/chat-session/chat-session.d.ts +31 -0
  161. package/dist-server/service/chat-session/chat-session.js +131 -0
  162. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  163. package/dist-server/service/chat-session/index.d.ts +3 -0
  164. package/dist-server/service/chat-session/index.js +7 -0
  165. package/dist-server/service/chat-session/index.js.map +1 -0
  166. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +30 -0
  167. package/dist-server/service/chat-session-participant/chat-session-participant.js +112 -0
  168. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  169. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  170. package/dist-server/service/chat-session-participant/index.js +10 -0
  171. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  172. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  173. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  174. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  175. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  176. package/dist-server/service/chat-session-participant/presence.js +22 -0
  177. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  178. package/dist-server/service/chat-session-resolver.d.ts +20 -0
  179. package/dist-server/service/chat-session-resolver.js +356 -0
  180. package/dist-server/service/chat-session-resolver.js.map +1 -0
  181. package/dist-server/service/component-tree.d.ts +68 -0
  182. package/dist-server/service/component-tree.js +73 -0
  183. package/dist-server/service/component-tree.js.map +1 -0
  184. package/dist-server/service/index.d.ts +20 -0
  185. package/dist-server/service/index.js +34 -0
  186. package/dist-server/service/index.js.map +1 -0
  187. package/dist-server/service/mention-marker.d.ts +78 -0
  188. package/dist-server/service/mention-marker.js +162 -0
  189. package/dist-server/service/mention-marker.js.map +1 -0
  190. package/dist-server/service/mention-resolver.d.ts +96 -0
  191. package/dist-server/service/mention-resolver.js +177 -0
  192. package/dist-server/service/mention-resolver.js.map +1 -0
  193. package/dist-server/service/patch-entry/board-patch-broadcast.d.ts +13 -0
  194. package/dist-server/service/patch-entry/board-patch-broadcast.js +13 -0
  195. package/dist-server/service/patch-entry/board-patch-broadcast.js.map +1 -0
  196. package/dist-server/service/patch-entry/board-patch-subscription.d.ts +12 -0
  197. package/dist-server/service/patch-entry/board-patch-subscription.js +63 -0
  198. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -0
  199. package/dist-server/service/patch-entry/index.d.ts +4 -0
  200. package/dist-server/service/patch-entry/index.js +12 -0
  201. package/dist-server/service/patch-entry/index.js.map +1 -0
  202. package/dist-server/service/patch-entry/patch-entry.d.ts +16 -0
  203. package/dist-server/service/patch-entry/patch-entry.js +96 -0
  204. package/dist-server/service/patch-entry/patch-entry.js.map +1 -0
  205. package/dist-server/service/prefab-registry.d.ts +73 -0
  206. package/dist-server/service/prefab-registry.js +258 -0
  207. package/dist-server/service/prefab-registry.js.map +1 -0
  208. package/dist-server/service/styling/clear-tools.d.ts +15 -0
  209. package/dist-server/service/styling/clear-tools.js +53 -0
  210. package/dist-server/service/styling/clear-tools.js.map +1 -0
  211. package/dist-server/service/styling/color-utils.d.ts +43 -0
  212. package/dist-server/service/styling/color-utils.js +210 -0
  213. package/dist-server/service/styling/color-utils.js.map +1 -0
  214. package/dist-server/service/styling/copy-tools.d.ts +27 -0
  215. package/dist-server/service/styling/copy-tools.js +81 -0
  216. package/dist-server/service/styling/copy-tools.js.map +1 -0
  217. package/dist-server/service/styling/effect-tools.d.ts +45 -0
  218. package/dist-server/service/styling/effect-tools.js +90 -0
  219. package/dist-server/service/styling/effect-tools.js.map +1 -0
  220. package/dist-server/service/styling/fill-tools.d.ts +60 -0
  221. package/dist-server/service/styling/fill-tools.js +229 -0
  222. package/dist-server/service/styling/fill-tools.js.map +1 -0
  223. package/dist-server/service/styling/material-tools.d.ts +32 -0
  224. package/dist-server/service/styling/material-tools.js +92 -0
  225. package/dist-server/service/styling/material-tools.js.map +1 -0
  226. package/dist-server/service/styling/read-tools.d.ts +34 -0
  227. package/dist-server/service/styling/read-tools.js +45 -0
  228. package/dist-server/service/styling/read-tools.js.map +1 -0
  229. package/dist-server/service/styling/registry.d.ts +91 -0
  230. package/dist-server/service/styling/registry.js +352 -0
  231. package/dist-server/service/styling/registry.js.map +1 -0
  232. package/dist-server/service/styling/stroke-tools.d.ts +35 -0
  233. package/dist-server/service/styling/stroke-tools.js +97 -0
  234. package/dist-server/service/styling/stroke-tools.js.map +1 -0
  235. package/dist-server/service/styling/text-tools.d.ts +32 -0
  236. package/dist-server/service/styling/text-tools.js +105 -0
  237. package/dist-server/service/styling/text-tools.js.map +1 -0
  238. package/dist-server/service/subscribe-auth.d.ts +14 -0
  239. package/dist-server/service/subscribe-auth.js +33 -0
  240. package/dist-server/service/subscribe-auth.js.map +1 -0
  241. package/dist-server/service/types.d.ts +265 -0
  242. package/dist-server/service/types.js +3 -0
  243. package/dist-server/service/types.js.map +1 -0
  244. package/dist-server/service/validation/board-model-schema.d.ts +8311 -0
  245. package/dist-server/service/validation/board-model-schema.js +409 -0
  246. package/dist-server/service/validation/board-model-schema.js.map +1 -0
  247. package/dist-server/service/validation/tool-validation.d.ts +31 -0
  248. package/dist-server/service/validation/tool-validation.js +196 -0
  249. package/dist-server/service/validation/tool-validation.js.map +1 -0
  250. package/dist-server/tsconfig.tsbuildinfo +1 -0
  251. package/package.json +48 -0
  252. package/server/index.ts +23 -0
  253. package/server/service/agentic-loop.test.ts +705 -0
  254. package/server/service/agentic-loop.ts +431 -0
  255. package/server/service/apply-patch-drift.test.ts +829 -0
  256. package/server/service/apply-patch.test.ts +757 -0
  257. package/server/service/apply-patch.ts +411 -0
  258. package/server/service/assistant-integration.test.ts +922 -0
  259. package/server/service/assistant-llm-smoke.test.ts +296 -0
  260. package/server/service/assistant.test.ts +2034 -0
  261. package/server/service/assistant.ts +2378 -0
  262. package/server/service/board-ai-resolver.ts +407 -0
  263. package/server/service/catalog-resolver.test.ts +137 -0
  264. package/server/service/catalog-resolver.ts +102 -0
  265. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  266. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  267. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  268. package/server/service/chat-message/chat-message-publish.ts +22 -0
  269. package/server/service/chat-message/chat-message-subscription.ts +41 -0
  270. package/server/service/chat-message/chat-message.ts +169 -0
  271. package/server/service/chat-message/index.ts +12 -0
  272. package/server/service/chat-session/chat-session.ts +130 -0
  273. package/server/service/chat-session/index.ts +5 -0
  274. package/server/service/chat-session-participant/chat-session-participant.ts +110 -0
  275. package/server/service/chat-session-participant/index.ts +6 -0
  276. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  277. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  278. package/server/service/chat-session-participant/presence.test.ts +39 -0
  279. package/server/service/chat-session-participant/presence.ts +21 -0
  280. package/server/service/chat-session-resolver.ts +312 -0
  281. package/server/service/component-tree.test.ts +248 -0
  282. package/server/service/component-tree.ts +135 -0
  283. package/server/service/index.ts +32 -0
  284. package/server/service/mention-marker.extract.test.ts +61 -0
  285. package/server/service/mention-marker.test.ts +228 -0
  286. package/server/service/mention-marker.ts +184 -0
  287. package/server/service/mention-resolver.test.ts +273 -0
  288. package/server/service/mention-resolver.ts +264 -0
  289. package/server/service/patch-entry/board-patch-broadcast.ts +24 -0
  290. package/server/service/patch-entry/board-patch-subscription.test.ts +40 -0
  291. package/server/service/patch-entry/board-patch-subscription.ts +50 -0
  292. package/server/service/patch-entry/index.ts +12 -0
  293. package/server/service/patch-entry/patch-entry.ts +89 -0
  294. package/server/service/prefab-registry.test.ts +172 -0
  295. package/server/service/prefab-registry.ts +332 -0
  296. package/server/service/styling/clear-tools.test.ts +164 -0
  297. package/server/service/styling/clear-tools.ts +69 -0
  298. package/server/service/styling/color-utils.test.ts +130 -0
  299. package/server/service/styling/color-utils.ts +226 -0
  300. package/server/service/styling/copy-tools.test.ts +152 -0
  301. package/server/service/styling/copy-tools.ts +115 -0
  302. package/server/service/styling/effect-tools.test.ts +277 -0
  303. package/server/service/styling/effect-tools.ts +124 -0
  304. package/server/service/styling/fill-tools.test.ts +331 -0
  305. package/server/service/styling/fill-tools.ts +293 -0
  306. package/server/service/styling/material-tools.test.ts +294 -0
  307. package/server/service/styling/material-tools.ts +114 -0
  308. package/server/service/styling/read-tools.test.ts +78 -0
  309. package/server/service/styling/read-tools.ts +67 -0
  310. package/server/service/styling/registry.test.ts +203 -0
  311. package/server/service/styling/registry.ts +423 -0
  312. package/server/service/styling/stroke-tools.test.ts +245 -0
  313. package/server/service/styling/stroke-tools.ts +145 -0
  314. package/server/service/styling/text-tools.test.ts +253 -0
  315. package/server/service/styling/text-tools.ts +131 -0
  316. package/server/service/styling/tier1-tools.test.ts +300 -0
  317. package/server/service/subscribe-auth.test.ts +57 -0
  318. package/server/service/subscribe-auth.ts +34 -0
  319. package/server/service/types.ts +235 -0
  320. package/server/service/validation/board-model-schema.test.ts +778 -0
  321. package/server/service/validation/board-model-schema.ts +446 -0
  322. package/server/service/validation/tool-validation.ts +228 -0
  323. package/things-factory.config.js +1 -0
  324. package/translations/en.json +49 -0
  325. package/translations/ja.json +48 -0
  326. package/translations/ko.json +49 -0
  327. package/translations/ms.json +48 -0
  328. package/translations/zh.json +48 -0
  329. package/tsconfig.json +9 -0
@@ -0,0 +1,2574 @@
1
+ /**
2
+ * <ox-board-ai-chat> — AI 주도 보드 모델링 채팅 컴포넌트 (Lit).
3
+ *
4
+ * 입력:
5
+ * - sessionId: 영속 ChatSession 식별자 (없으면 ad-hoc 모드, 메시지 영속 안 됨)
6
+ * - currentBoard: 현재 BoardModel JSON (호스트가 동기화)
7
+ * - scopes / knownTypes / categories: 도메인 컨텍스트
8
+ *
9
+ * 출력 (이벤트):
10
+ * - `board-edit-patch` { detail: { patch, summary, confidence, patchId } }
11
+ * 호스트가 받아서 보드 모델에 적용 (applyBoardEditPatch helper).
12
+ * - `chat-followup` { detail: { question } }
13
+ *
14
+ * 모드 전환은 컨테이너 (워크스페이스) 의 책임. 이 컴포넌트는 자체로 풀 채팅 UX.
15
+ */
16
+ import '@material/web/icon/icon.js'
17
+
18
+ import { LitElement, css, html, nothing } from 'lit'
19
+ import { customElement, property, state } from 'lit/decorators.js'
20
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
21
+ import { client } from '@operato/graphql'
22
+ import { auth } from '@things-factory/auth-base/dist-client/index.js'
23
+ import { i18next, localize } from '@operato/i18n'
24
+ import { ScrollbarStyles } from '@operato/styles'
25
+ import gql from 'graphql-tag'
26
+
27
+ import {
28
+ renderMarkdown,
29
+ injectMentionRefids,
30
+ injectMentionUserIds,
31
+ stripMentionRefids
32
+ } from './markdown.js'
33
+ import { buildChatMutationInput } from './chat-input-builder.js'
34
+ import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
35
+ import './mention-popup.js'
36
+ import {
37
+ buildMentionCandidates,
38
+ buildCatalogCandidates,
39
+ buildSlashCandidates,
40
+ buildUserCandidates,
41
+ extractMentionContext,
42
+ filterMentionCandidates,
43
+ applyMentionSelection,
44
+ resolveMentionToken,
45
+ type MentionCandidate,
46
+ type FilterResult
47
+ } from './mention-popup.js'
48
+ import { DEFAULT_CATALOG_ENTRIES } from './default-catalog-entries.js'
49
+ import { DEFAULT_SLASH_TEMPLATES } from './default-slash-templates.js'
50
+
51
+ interface ChatLine {
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
60
+ /** 메시지 본문. `#token{refid:N}` inline marker 가 포함될 수 있음 (영속된 메시지 / 사용자
61
+ * popup pick / AI resolved mention). 렌더링 시 추출해서 `<span data-refid="N">` 으로 변환. */
62
+ content: string
63
+ patchSummary?: string
64
+ followUp?: string
65
+ patchId?: string
66
+ pending?: boolean
67
+ /** AI 가 응답을 만드는 동안 호출한 도구 trace — fold-able 박스에서 노출. */
68
+ toolUsages?: Array<{ name: string; arguments: any; result: any; kind: 'read' | 'write' | 'unknown' }>
69
+ /** UI 상태: tool usage 박스 펼침 여부 (assistant 메시지마다 독립). */
70
+ toolUsagesOpen?: boolean
71
+ }
72
+
73
+ const BOARD_AI_CHAT_MUTATION = gql`
74
+ mutation BoardAIChat($input: BoardAIChatInput!) {
75
+ boardAIChat(input: $input) {
76
+ reply
77
+ patch
78
+ followUp
79
+ clientId
80
+ sessionId
81
+ userMessageId
82
+ assistantMessageId
83
+ patchId
84
+ toolUsages
85
+ actions
86
+ }
87
+ }
88
+ `
89
+
90
+ const CHAT_MESSAGES_QUERY = gql`
91
+ query ChatMessages($sessionId: String!, $limit: Int, $offset: Int) {
92
+ chatMessages(sessionId: $sessionId, limit: $limit, offset: $offset) {
93
+ id
94
+ role
95
+ content
96
+ relatedPatchId
97
+ toolUsagesJson
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
+ }
122
+ }
123
+ }
124
+ `
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
+
158
+ @customElement('ox-board-ai-chat')
159
+ export class OxBoardAIChat extends LitElement {
160
+ /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
161
+ @property({ type: String, attribute: 'session-id' })
162
+ sessionId?: string
163
+
164
+ /**
165
+ * 보드의 모든 ChatSession 리스트 — 다중 세션 활성화 시 탭 UI 로 표시.
166
+ *
167
+ * 빈 배열 / 미지정 → 단일 세션 UX (탭 안 보임). 한 보드 = 한 세션 가정의 기존 호출자
168
+ * 호환. host (board-modeller-page) 가 chatSessionsByBoard query 결과를 그대로 전달.
169
+ *
170
+ * 이 컴포넌트는 list 자체를 fetch 하지 않음 — 영속 모델 / 권한 / 페이지네이션 정책은
171
+ * host 책임. 본 컴포넌트는 표시 + 이벤트 emission 만.
172
+ */
173
+ @property({ type: Array, attribute: false })
174
+ sessions: Array<{ id: string; name?: string; createdAt?: string }> = []
175
+
176
+ /**
177
+ * 현재 BoardModel — chat() 입력으로 그대로 전달.
178
+ *
179
+ * 주의: 호스트가 라이브 편집 가능한 캔버스(things-scene)를 갖고 있다면, 캔버스 안에서
180
+ * 일어나는 수작업 편집이 이 property 에는 자동 반영되지 않는다. 이 경우 `boardProvider`
181
+ * 콜백을 같이 넘기면 send() 시점에 라이브 보드를 pull 한다.
182
+ */
183
+ @property({ type: Object })
184
+ currentBoard?: any
185
+
186
+ /**
187
+ * 라이브 보드 모델 공급자 (선택).
188
+ *
189
+ * 호스트가 things-scene 같은 캔버스를 사용 중이면 사용자가 캔버스에서 수작업 편집한
190
+ * 내용은 `currentBoard` property 에 자동 반영되지 않는다. 이 콜백을 등록하면 `send()`
191
+ * 직전에 호출해 가장 최신 보드를 pull → AI 가 항상 라이브 상태를 본다.
192
+ *
193
+ * 우선순위: boardProvider() ?? currentBoard.
194
+ */
195
+ @property({ attribute: false })
196
+ boardProvider?: () => any
197
+
198
+ /**
199
+ * `#` mention 검색 공급자 (선택). 호스트가 things-scene 의 SceneSearchEngine 으로 wrap.
200
+ *
201
+ * 호스트는 query 를 받아 popup 에 표시할 FilterResult[] 를 돌려준다 — chat 자체는
202
+ * things-scene 의존성을 갖지 않고 popup 표시 로직만 담당. 검색 우선순위 / 매칭 룰은
203
+ * SceneSearchEngine (F-Finder 와 공유) 에 위임 → 일관성 보장.
204
+ *
205
+ * 미설정 시 `currentBoard.components` 에서 단순 substring 매칭 (legacy fallback).
206
+ * 빈 query 에서는 호출되지 않음 — 빈 query 는 전체 후보 enumeration 이지 검색이 아님.
207
+ */
208
+ @property({ attribute: false })
209
+ searchProvider?: (query: string) => FilterResult[]
210
+
211
+ /**
212
+ * `@` mention 도메인 사용자 공급자 (선택). 호스트가 `domainUsersForMention` query 로 wrap.
213
+ *
214
+ * 호스트는 query 문자열을 받아 도메인 사용자 후보 (id/name/email) 배열을 돌려준다.
215
+ * 미설정 시 `@` popup 은 빈 결과 (사용자가 멘션 못 함). 멀티유저 환경 활성화 정책에 따라
216
+ * 호스트가 set 해야 활성화. async 가 자연스러우므로 Promise 반환 가능.
217
+ */
218
+ @property({ attribute: false })
219
+ userProvider?: (
220
+ query: string
221
+ ) =>
222
+ | Promise<Array<{ id: string; name?: string | null; email?: string | null }>>
223
+ | Array<{ id: string; name?: string | null; email?: string | null }>
224
+
225
+ /**
226
+ * 모델러에서 현재 선택된 컴포넌트의 `refid` 목록.
227
+ *
228
+ * refid 는 things-scene 이 모든 컴포넌트에 자동 발급하는 universal numeric handle.
229
+ * 호스트가 매 render 마다 `.selectedRefids=${...}` 로 갱신. AI 한테 selection 정보로
230
+ * 그대로 전달되어 "선택한" / "selected" / "this" 류 지시어 해석에 사용.
231
+ */
232
+ @property({ type: Array, attribute: false })
233
+ selectedRefids: number[] = []
234
+
235
+ /** board-import registry scopes. */
236
+ @property({ type: Array })
237
+ scopes?: string[]
238
+
239
+ @property({ type: Array, attribute: 'known-types' })
240
+ knownTypes?: string[]
241
+
242
+ @property({ type: Array })
243
+ categories?: string[]
244
+
245
+ /**
246
+ * 컴포넌트 type 별 유효 속성 스킴.
247
+ * 형태: [{ type, description?, group?, properties? }]
248
+ * LLM 이 정확한 컴포넌트를 만들도록 mutation input 으로 forwarding.
249
+ */
250
+ @property({ type: Array, attribute: false })
251
+ componentSchemas?: Array<{ type: string; description?: string; group?: string; properties?: Record<string, any> }>
252
+
253
+ @property({ type: String })
254
+ placeholder = '자연어로 명령하세요 (Shift+Enter 줄바꿈, Enter 전송)'
255
+
256
+ @state()
257
+ private lines: ChatLine[] = []
258
+
259
+ @state()
260
+ private input = ''
261
+
262
+ /** 마지막 send 실패 시 보존 — 다시 시도 버튼 클릭 시 사용 */
263
+ @state()
264
+ private lastFailedInput?: string
265
+
266
+ /** 되돌려진 patch id 들 — 한 번 revert 한 patch 는 버튼 비활성 */
267
+ @state()
268
+ private revertedPatchIds = new Set<string>()
269
+
270
+ /** mini action — 인라인 예시 토글 */
271
+ @state()
272
+ private examplesOpen = false
273
+
274
+ /** 복사 toast 표시 상태 */
275
+ @state()
276
+ private toastMessage?: string
277
+
278
+ /** 복사 confirmation feedback (메시지 idx → 짧은 시간 동안 ✓ 표시) */
279
+ @state()
280
+ private copiedIdx?: number
281
+
282
+ @state()
283
+ private busy = false
284
+
285
+ @state()
286
+ private errorMessage?: string
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
+
310
+ /** Mention popup — `#` (컴포넌트) / `@` (카탈로그) / `/` (slash) 트리거 시 활성.
311
+ * 단일 popup 재사용 — mentionTrigger 로 활성 trigger 추적. */
312
+ @state()
313
+ private mentionOpen = false
314
+
315
+ /** popup 표시용 — F-Finder 처럼 matchType + matchValue 동봉된 결과 */
316
+ @state()
317
+ private mentionResults: FilterResult[] = []
318
+
319
+ @state()
320
+ private mentionActiveIndex = 0
321
+
322
+ /** filter cap (50) 으로 잘라낸 추가 매칭 수 — popup 의 'N more' 표시용 */
323
+ @state()
324
+ private mentionOverflow = 0
325
+
326
+ /** 현재 활성 mention 의 textarea 내 trigger 위치 */
327
+ private mentionHashPos = -1
328
+
329
+ /** 현재 활성 trigger char — '#' / '@' / '/'. popup 비활성 시 의미 없음. */
330
+ private mentionTrigger: '#' | '@' | '/' = '#'
331
+
332
+ /** popup 열기 직전 사용자 selection 스냅샷 — Esc/blur close 시 원복용 */
333
+ private originalSelectedRefids: number[] = []
334
+
335
+ /** popup 에서 명시 선택된 #mention 매핑 — {token: refid}.
336
+ * 사용자 textarea 에는 #token 만 보이지만 server 한테는 정확한 refid 전달.
337
+ * send 시 mutation input.mentions 로 함께 전송. 사용자가 textarea 비우거나
338
+ * 새 채팅 시작하면 정리. */
339
+ private pickedMentions: Map<string, number> = new Map()
340
+
341
+ /** popup 에서 명시 선택된 @user 멘션 매핑 — {token: userId}.
342
+ * textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
343
+ * send 시 mutation input.userMentions 로 함께 전송. */
344
+ private pickedUserMentions: Map<string, string> = new Map()
345
+
346
+ /** input 디바운스 timer — 150ms 후 refreshMentionPopup 실행 (F-Finder 와 동일) */
347
+ private inputDebounceTimer?: ReturnType<typeof setTimeout>
348
+
349
+ /** centerTo 디바운스 timer — arrow nav / hover 빠를 때 카메라 애니메이션 race 방지 */
350
+ private centerDebounceTimer?: ReturnType<typeof setTimeout>
351
+
352
+ /** Default 카탈로그 후보 캐시 — chat panel mount 시 1회 빌드 */
353
+ private cachedCatalogCandidates: MentionCandidate[] = buildCatalogCandidates(
354
+ DEFAULT_CATALOG_ENTRIES
355
+ )
356
+
357
+ /** Default slash template 후보 캐시 — chat panel mount 시 1회 빌드 */
358
+ private cachedSlashCandidates: MentionCandidate[] = buildSlashCandidates(
359
+ DEFAULT_SLASH_TEMPLATES
360
+ )
361
+
362
+ /** `@` 마지막 query 의 도메인 사용자 후보 캐시 — userProvider 응답 도착 시 갱신.
363
+ * query 별 캐시가 아니라 단순 "최근 결과" — async race 회피. */
364
+ private cachedUserCandidates: MentionCandidate[] = []
365
+
366
+ /** 진행 중인 userProvider fetch token — 늦게 도착한 응답이 최신을 덮어쓰지 않도록 식별. */
367
+ private userFetchToken = 0
368
+
369
+ static styles = [
370
+ // 어플리케이션 표준 스크롤바 (--scrollbar-width / --scrollbar-thumb-color 등 호스트 변수 따름)
371
+ ScrollbarStyles,
372
+ css`
373
+ /*
374
+ Material Design 3 토큰 사용 — 사용자 테마 (라이트/다크/브랜드) 자동 반영.
375
+ 각 var() 의 두 번째 인자는 토큰 미정의 시 fallback (현 디자인 톤).
376
+ */
377
+ :host {
378
+ display: flex;
379
+ flex-direction: column;
380
+ height: 100%;
381
+ min-height: 240px;
382
+ position: relative;
383
+ font:
384
+ 400 11px / 1.55 -apple-system,
385
+ BlinkMacSystemFont,
386
+ 'Inter',
387
+ 'Pretendard',
388
+ 'Segoe UI',
389
+ Roboto,
390
+ sans-serif;
391
+ letter-spacing: -0.005em;
392
+ color: var(--md-sys-color-on-surface, #0f172a);
393
+ background: var(--md-sys-color-surface, #ffffff);
394
+ }
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
+
440
+ /* ── Session tabs ─────────────────────────────────── */
441
+ .session-tabs {
442
+ display: flex;
443
+ align-items: center;
444
+ gap: 2px;
445
+ padding: 4px 6px 0 6px;
446
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
447
+ background: var(--md-sys-color-surface-container-low, #fafbfc);
448
+ overflow-x: auto;
449
+ scrollbar-width: thin;
450
+ flex-shrink: 0;
451
+ }
452
+ .session-tab {
453
+ flex-shrink: 0;
454
+ padding: 4px 10px;
455
+ font-size: 11px;
456
+ line-height: 1.4;
457
+ color: var(--md-sys-color-on-surface-variant, #64748b);
458
+ background: transparent;
459
+ border: 1px solid transparent;
460
+ border-bottom: 0;
461
+ border-top-left-radius: 5px;
462
+ border-top-right-radius: 5px;
463
+ cursor: pointer;
464
+ max-width: 160px;
465
+ white-space: nowrap;
466
+ overflow: hidden;
467
+ text-overflow: ellipsis;
468
+ }
469
+ .session-tab:hover {
470
+ background: var(--md-sys-color-surface-container, #f1f5f9);
471
+ }
472
+ .session-tab.active {
473
+ background: var(--md-sys-color-surface, #ffffff);
474
+ color: var(--md-sys-color-on-surface, #0f172a);
475
+ border-color: var(--md-sys-color-outline-variant, #e2e8f0);
476
+ font-weight: 500;
477
+ position: relative;
478
+ }
479
+ .session-tab.active::after {
480
+ content: '';
481
+ position: absolute;
482
+ left: 0;
483
+ right: 0;
484
+ bottom: -1px;
485
+ height: 1px;
486
+ background: var(--md-sys-color-surface, #ffffff);
487
+ }
488
+ .session-tab-new {
489
+ flex-shrink: 0;
490
+ padding: 4px 8px 4px 6px;
491
+ background: transparent;
492
+ border: 0;
493
+ cursor: pointer;
494
+ color: var(--md-sys-color-on-surface-variant, #64748b);
495
+ display: inline-flex;
496
+ align-items: center;
497
+ gap: 2px;
498
+ font-size: 11px;
499
+ line-height: 1.4;
500
+ border-radius: 5px;
501
+ white-space: nowrap;
502
+ }
503
+ .session-tab-new:hover {
504
+ color: var(--md-sys-color-primary, #2563eb);
505
+ background: var(--md-sys-color-surface-container, #f1f5f9);
506
+ }
507
+ .session-tab-new md-icon {
508
+ --md-icon-size: 14px;
509
+ font-size: 14px;
510
+ }
511
+
512
+ /* ── Messages ─────────────────────────────────────── */
513
+ /* 스크롤바는 ScrollbarStyles (위쪽 styles 배열) 가 처리 — 어플리케이션 표준 따름 */
514
+ .messages {
515
+ flex: 1;
516
+ overflow-y: auto;
517
+ overflow-x: hidden; /* 가로 스크롤 차단 — 자식 (empty / msg) 내부에서 wrap 처리 */
518
+ padding: 10px 16px 4px;
519
+ display: flex;
520
+ flex-direction: column;
521
+ gap: 4px; /* 단일 spacing source — .msg-wrap 의 자체 margin 은 0 */
522
+ scroll-behavior: smooth;
523
+ min-width: 0; /* flex item 의 기본 min-content 무시 — 자식 ellipsis/wrap 동작 */
524
+ }
525
+
526
+ /* ── Empty state — onboarding 가이드 ───────────────── */
527
+ .empty {
528
+ margin: auto;
529
+ text-align: left;
530
+ width: 100%;
531
+ max-width: 360px;
532
+ box-sizing: border-box;
533
+ min-width: 0;
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 16px;
537
+ word-break: break-word; /* 긴 한글 / URL 도 줄바꿈해 가로 overflow 방지 */
538
+ overflow-wrap: anywhere;
539
+ }
540
+ .empty .header {
541
+ text-align: center;
542
+ padding: 8px 0 4px;
543
+ }
544
+ .empty .icon {
545
+ font-size: 22px;
546
+ opacity: 0.4;
547
+ margin-bottom: 8px;
548
+ color: var(--md-sys-color-primary, currentColor);
549
+ display: block;
550
+ }
551
+ .empty .title {
552
+ color: var(--md-sys-color-on-surface, #0f172a);
553
+ font-size: 12.5px;
554
+ font-weight: 600;
555
+ margin-bottom: 4px;
556
+ letter-spacing: -0.01em;
557
+ }
558
+ .empty .subtitle {
559
+ color: var(--md-sys-color-on-surface-variant, #475569);
560
+ font-size: 10.5px;
561
+ line-height: 1.5;
562
+ }
563
+
564
+ .empty .group {
565
+ display: flex;
566
+ flex-direction: column;
567
+ gap: 4px;
568
+ }
569
+ .empty .group-label {
570
+ font-size: 9.5px;
571
+ color: var(--md-sys-color-outline, #94a3b8);
572
+ letter-spacing: 0.06em;
573
+ text-transform: uppercase;
574
+ font-weight: 600;
575
+ padding: 0 2px;
576
+ }
577
+ .empty .example {
578
+ all: unset;
579
+ display: block;
580
+ cursor: pointer;
581
+ padding: 7px 10px;
582
+ font-size: 11px;
583
+ color: var(--md-sys-color-on-surface, #0f172a);
584
+ background: var(--md-sys-color-surface-container, #f8fafc);
585
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
586
+ border-radius: 8px;
587
+ line-height: 1.5;
588
+ box-sizing: border-box;
589
+ width: 100%;
590
+ max-width: 100%;
591
+ word-break: break-word;
592
+ overflow-wrap: anywhere;
593
+ transition:
594
+ background 0.15s,
595
+ border-color 0.15s,
596
+ transform 0.1s;
597
+ }
598
+ .empty .example:hover {
599
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
600
+ border-color: var(--md-sys-color-outline, #cbd5e1);
601
+ }
602
+ .empty .example:active {
603
+ transform: scale(0.99);
604
+ }
605
+ .empty .example .arrow {
606
+ float: right;
607
+ opacity: 0.4;
608
+ margin-left: 8px;
609
+ font-size: 11px;
610
+ }
611
+
612
+ .empty .footer {
613
+ margin-top: 4px;
614
+ padding: 8px 4px 0;
615
+ border-top: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
616
+ color: var(--md-sys-color-outline, #94a3b8);
617
+ font-size: 10px;
618
+ line-height: 1.6;
619
+ text-align: center;
620
+ }
621
+ .empty .footer .badge {
622
+ display: inline-block;
623
+ margin: 0 4px;
624
+ padding: 1px 7px;
625
+ background: var(--md-sys-color-surface-container, #f8fafc);
626
+ border-radius: 8px;
627
+ font-size: 9.5px;
628
+ color: var(--md-sys-color-on-surface-variant, #64748b);
629
+ }
630
+ /* ── Trigger chip row (empty state) ──────────────── */
631
+ .empty .trigger-row {
632
+ margin: 12px 0 4px;
633
+ padding: 10px 12px;
634
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
635
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
636
+ border-radius: 10px;
637
+ }
638
+ .empty .trigger-row-label {
639
+ font-size: 10px;
640
+ color: var(--md-sys-color-on-surface-variant, #64748b);
641
+ margin-bottom: 6px;
642
+ text-align: center;
643
+ letter-spacing: 0.02em;
644
+ }
645
+ .empty .trigger-chips {
646
+ display: flex;
647
+ gap: 6px;
648
+ justify-content: center;
649
+ flex-wrap: wrap;
650
+ }
651
+ .empty .trigger-chip {
652
+ display: inline-flex;
653
+ align-items: center;
654
+ gap: 5px;
655
+ padding: 5px 10px;
656
+ background: var(--md-sys-color-surface, #ffffff);
657
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
658
+ border-radius: 18px;
659
+ cursor: pointer;
660
+ font-size: 11.5px;
661
+ color: var(--md-sys-color-on-surface, #0f172a);
662
+ transition:
663
+ border-color 0.15s,
664
+ background 0.15s,
665
+ color 0.15s;
666
+ }
667
+ .empty .trigger-chip:hover {
668
+ border-color: var(--md-sys-color-primary, #2563eb);
669
+ background: var(--md-sys-color-primary-container, #dbeafe);
670
+ color: var(--md-sys-color-on-primary-container, #1e3a8a);
671
+ }
672
+ .empty .trigger-chip .trigger-char {
673
+ font-family:
674
+ 'SF Mono',
675
+ Menlo,
676
+ Monaco,
677
+ Consolas,
678
+ monospace;
679
+ font-size: 13px;
680
+ font-weight: 600;
681
+ color: var(--md-sys-color-primary, #2563eb);
682
+ min-width: 12px;
683
+ text-align: center;
684
+ }
685
+ .empty .trigger-chip:hover .trigger-char {
686
+ color: var(--md-sys-color-on-primary-container, #1e3a8a);
687
+ }
688
+ .empty .trigger-chip .trigger-desc {
689
+ font-weight: 500;
690
+ }
691
+
692
+ /* ── Message label (sender hint) ──────────────────── */
693
+ .msg-label {
694
+ font-size: 9.5px;
695
+ color: var(--md-sys-color-outline, #94a3b8);
696
+ letter-spacing: 0.05em;
697
+ font-weight: 500;
698
+ text-transform: uppercase;
699
+ margin: 4px 0 1px 4px;
700
+ align-self: flex-start;
701
+ }
702
+ .msg-label:first-child {
703
+ margin-top: 0;
704
+ }
705
+
706
+ /* ── Message wrapper (메시지 + hover 액션) ────────── */
707
+ .msg-wrap {
708
+ display: flex;
709
+ flex-direction: column;
710
+ width: fit-content;
711
+ max-width: min(92%, 420px);
712
+ /* margin 0 — spacing 은 .messages gap 단일 source */
713
+ }
714
+ .msg-wrap.user {
715
+ align-self: flex-end;
716
+ align-items: flex-end;
717
+ }
718
+ .msg-wrap.assistant {
719
+ align-self: flex-start;
720
+ align-items: flex-start;
721
+ }
722
+ .msg-wrap.system {
723
+ align-self: center;
724
+ align-items: center;
725
+ max-width: 100%;
726
+ }
727
+
728
+ /* ── Message bubble ───────────────────────────────── */
729
+ .msg {
730
+ display: inline-block;
731
+ width: fit-content;
732
+ max-width: 100%;
733
+ padding: 5px 9px;
734
+ border-radius: 10px;
735
+ white-space: normal;
736
+ overflow-wrap: anywhere;
737
+ font-size: 11px;
738
+ line-height: 1.45;
739
+ }
740
+
741
+ .msg.user {
742
+ align-self: flex-end;
743
+ background: var(--md-sys-color-primary, #1e293b);
744
+ color: var(--md-sys-color-on-primary, #f8fafc);
745
+ border-radius: 12px 12px 3px 12px;
746
+ /* margin-top 0 — .messages gap 으로만 spacing */
747
+ }
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
+
767
+ .msg.assistant {
768
+ align-self: flex-start;
769
+ background: var(--md-sys-color-surface-container, #f8fafc);
770
+ color: var(--md-sys-color-on-surface, #0f172a);
771
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
772
+ border-radius: 12px 12px 12px 3px;
773
+ }
774
+
775
+ /* ── Markdown 렌더 (AI 응답) ──────────────────────── */
776
+ .md-body {
777
+ display: block;
778
+ }
779
+ .md-body > :first-child {
780
+ margin-top: 0;
781
+ }
782
+ .md-body > :last-child {
783
+ margin-bottom: 0;
784
+ }
785
+ .md-body p {
786
+ margin: 0 0 4px;
787
+ line-height: 1.45;
788
+ }
789
+ .md-body ul,
790
+ .md-body ol {
791
+ margin: 2px 0 4px;
792
+ padding-left: 16px;
793
+ }
794
+ .md-body li {
795
+ margin: 0;
796
+ line-height: 1.45;
797
+ }
798
+ .md-body li > p {
799
+ margin: 0;
800
+ }
801
+ .md-body strong {
802
+ font-weight: 600;
803
+ }
804
+ .md-body em {
805
+ font-style: italic;
806
+ }
807
+ /* '#token' mention — popup 의 match-badge 톤과 맞춤. 채팅 버블에서 컴포넌트
808
+ 참조임을 시각적으로 구분 (사용자/AI 메시지 양쪽). */
809
+ .md-body .mention {
810
+ display: inline-block;
811
+ padding: 0 5px;
812
+ border-radius: 4px;
813
+ background: var(--md-sys-color-primary-container, rgba(37, 99, 235, 0.12));
814
+ color: var(--md-sys-color-on-primary-container, #1e40af);
815
+ font-weight: 500;
816
+ white-space: nowrap;
817
+ cursor: pointer;
818
+ transition: background 0.12s;
819
+ }
820
+ .md-body .mention:hover {
821
+ background: var(--md-sys-color-primary, rgba(37, 99, 235, 0.25));
822
+ color: var(--md-sys-color-on-primary, #ffffff);
823
+ }
824
+ .msg.user .md-body .mention {
825
+ background: rgba(255, 255, 255, 0.22);
826
+ color: inherit;
827
+ }
828
+ .msg.user .md-body .mention:hover {
829
+ background: rgba(255, 255, 255, 0.40);
830
+ }
831
+ .md-body code {
832
+ font-family:
833
+ 'SF Mono',
834
+ Menlo,
835
+ Monaco,
836
+ Consolas,
837
+ monospace;
838
+ font-size: 10px;
839
+ padding: 1px 4px;
840
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
841
+ border-radius: 3px;
842
+ }
843
+ .md-body pre {
844
+ margin: 5px 0;
845
+ padding: 6px 8px;
846
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.06));
847
+ border-radius: 5px;
848
+ overflow-x: auto;
849
+ font-size: 10px;
850
+ line-height: 1.5;
851
+ }
852
+ /* user 메시지(어두운 배경) 위에서는 code/pre 의 contrast 보정 */
853
+ .msg.user .md-body code,
854
+ .msg.user .md-body pre {
855
+ background: rgba(255, 255, 255, 0.15);
856
+ }
857
+ .msg.user .md-body a {
858
+ color: inherit;
859
+ text-decoration: underline;
860
+ text-underline-offset: 2px;
861
+ }
862
+ .msg.user .md-body blockquote {
863
+ border-left-color: rgba(255, 255, 255, 0.3);
864
+ color: rgba(255, 255, 255, 0.85);
865
+ }
866
+ .msg.user .md-body hr {
867
+ border-top-color: rgba(255, 255, 255, 0.2);
868
+ }
869
+ .md-body pre code {
870
+ padding: 0;
871
+ background: transparent;
872
+ font-size: inherit;
873
+ }
874
+ .md-body a {
875
+ color: var(--md-sys-color-primary, #1e293b);
876
+ text-decoration: underline;
877
+ text-underline-offset: 2px;
878
+ }
879
+ .md-body a:hover {
880
+ text-decoration: none;
881
+ }
882
+ .md-body h1,
883
+ .md-body h2,
884
+ .md-body h3,
885
+ .md-body h4 {
886
+ margin: 4px 0 2px;
887
+ font-weight: 600;
888
+ line-height: 1.35;
889
+ }
890
+ .md-body h1 { font-size: 13px; }
891
+ .md-body h2 { font-size: 12px; }
892
+ .md-body h3,
893
+ .md-body h4 { font-size: 11.5px; }
894
+ .md-body blockquote {
895
+ margin: 4px 0;
896
+ padding: 1px 8px;
897
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
898
+ color: var(--md-sys-color-on-surface-variant, #64748b);
899
+ }
900
+ .md-body hr {
901
+ border: 0;
902
+ border-top: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
903
+ margin: 6px 0;
904
+ }
905
+ .md-body table {
906
+ border-collapse: collapse;
907
+ margin: 4px 0;
908
+ font-size: 10px;
909
+ }
910
+ .md-body th,
911
+ .md-body td {
912
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
913
+ padding: 2px 6px;
914
+ }
915
+ .md-body th {
916
+ background: var(--md-sys-color-surface-container, #f8fafc);
917
+ font-weight: 600;
918
+ }
919
+
920
+ .msg.system {
921
+ align-self: center;
922
+ background: transparent;
923
+ color: var(--md-sys-color-outline, #94a3b8);
924
+ font-size: 10px;
925
+ font-style: italic;
926
+ max-width: 100%;
927
+ padding: 2px 8px;
928
+ margin: 0;
929
+ border: 0;
930
+ }
931
+
932
+ .msg.pending {
933
+ opacity: 0.5;
934
+ }
935
+ .typing-dots {
936
+ display: inline-flex;
937
+ gap: 4px;
938
+ align-items: center;
939
+ vertical-align: middle;
940
+ }
941
+ .typing-dots > span {
942
+ display: inline-block;
943
+ width: 6px;
944
+ height: 6px;
945
+ border-radius: 50%;
946
+ background: currentColor;
947
+ opacity: 0.4;
948
+ animation: pulse 1.2s ease-in-out infinite;
949
+ }
950
+ .typing-dots > span:nth-child(2) { animation-delay: 0.2s; }
951
+ .typing-dots > span:nth-child(3) { animation-delay: 0.4s; }
952
+ @keyframes pulse {
953
+ 0%, 100% { opacity: 0.2; transform: scale(0.85); }
954
+ 50% { opacity: 0.7; transform: scale(1.1); }
955
+ }
956
+
957
+ /* ── Patch indicator + 인라인 되돌리기 ─────────────── */
958
+ .summary {
959
+ display: inline-flex;
960
+ align-items: center;
961
+ gap: 6px;
962
+ margin-top: 4px;
963
+ padding: 3px 4px 3px 8px;
964
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
965
+ border-radius: 6px;
966
+ font-size: 10px;
967
+ color: var(--md-sys-color-on-surface-variant, #475569);
968
+ line-height: 1;
969
+ }
970
+ .summary md-icon {
971
+ --md-icon-size: 13px;
972
+ color: var(--md-sys-color-tertiary, #10b981);
973
+ }
974
+ .summary.reverted {
975
+ opacity: 0.6;
976
+ }
977
+ .summary.reverted md-icon {
978
+ color: var(--md-sys-color-outline, #94a3b8);
979
+ }
980
+ .summary .summary-text {
981
+ letter-spacing: 0.01em;
982
+ }
983
+ .summary .revert-btn {
984
+ height: 20px;
985
+ padding: 0 8px;
986
+ background: transparent;
987
+ border: 0;
988
+ border-radius: 4px;
989
+ cursor: pointer;
990
+ font-family: inherit;
991
+ letter-spacing: inherit;
992
+ font-size: 10px;
993
+ color: var(--md-sys-color-on-surface-variant, #475569);
994
+ line-height: 1;
995
+ transition: background 0.12s, color 0.12s;
996
+ width: auto;
997
+ }
998
+ .summary .revert-btn:hover:not(:disabled) {
999
+ background: var(--md-sys-color-surface, #ffffff);
1000
+ color: var(--md-sys-color-on-surface, #0f172a);
1001
+ }
1002
+ .summary.reverted .revert-btn,
1003
+ .summary .revert-btn:disabled {
1004
+ display: none;
1005
+ }
1006
+
1007
+ /* ── Follow-up question ───────────────────────────── */
1008
+ .followup {
1009
+ font-size: 10.5px;
1010
+ color: var(--md-sys-color-on-secondary-container, #92400e);
1011
+ background: var(--md-sys-color-secondary-container, #fffbeb);
1012
+ border-left: 2px solid var(--md-sys-color-secondary, #f59e0b);
1013
+ padding: 5px 8px;
1014
+ border-radius: 0 5px 5px 0;
1015
+ margin-top: 4px;
1016
+ line-height: 1.45;
1017
+ }
1018
+
1019
+ /* ── Tool usages fold-able 박스 — AI 도구 호출 trace ───── */
1020
+ .tool-usages {
1021
+ margin-top: 4px;
1022
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1023
+ border-radius: 6px;
1024
+ background: var(--md-sys-color-surface-container-low, #f8fafc);
1025
+ overflow: hidden;
1026
+ font-size: 10.5px;
1027
+ }
1028
+ .tool-usages-header {
1029
+ display: flex;
1030
+ align-items: center;
1031
+ gap: 6px;
1032
+ width: 100%;
1033
+ padding: 5px 8px;
1034
+ background: transparent;
1035
+ border: 0;
1036
+ cursor: pointer;
1037
+ color: var(--md-sys-color-on-surface-variant, #475569);
1038
+ font-family: inherit;
1039
+ font-size: inherit;
1040
+ letter-spacing: inherit;
1041
+ text-align: left;
1042
+ }
1043
+ .tool-usages-header:hover {
1044
+ background: var(--md-sys-color-surface-container, #f1f5f9);
1045
+ }
1046
+ .tool-usages-icon,
1047
+ .tool-usages-chevron {
1048
+ --md-icon-size: 14px;
1049
+ flex-shrink: 0;
1050
+ }
1051
+ .tool-usages-summary {
1052
+ flex: 1;
1053
+ font-weight: 500;
1054
+ }
1055
+ .tool-usages-counts {
1056
+ color: var(--md-sys-color-outline, #94a3b8);
1057
+ font-weight: 400;
1058
+ font-size: 10px;
1059
+ }
1060
+ .tool-usages-list {
1061
+ list-style: none;
1062
+ margin: 0;
1063
+ padding: 0 8px 8px;
1064
+ display: flex;
1065
+ flex-direction: column;
1066
+ gap: 6px;
1067
+ }
1068
+ .tool-usage-item {
1069
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
1070
+ padding: 4px 8px 4px 8px;
1071
+ background: var(--md-sys-color-surface, #ffffff);
1072
+ border-radius: 0 4px 4px 0;
1073
+ }
1074
+ .tool-usage-item.kind-read {
1075
+ border-left-color: var(--md-sys-color-tertiary, #10b981);
1076
+ }
1077
+ .tool-usage-item.kind-write {
1078
+ border-left-color: var(--md-sys-color-primary, #1e293b);
1079
+ }
1080
+ .tool-usage-item.kind-unknown {
1081
+ border-left-color: var(--md-sys-color-error, #b91c1c);
1082
+ }
1083
+ .tool-usage-head {
1084
+ display: flex;
1085
+ align-items: center;
1086
+ gap: 6px;
1087
+ margin-bottom: 3px;
1088
+ }
1089
+ .tool-usage-step {
1090
+ color: var(--md-sys-color-outline, #94a3b8);
1091
+ font-size: 10px;
1092
+ }
1093
+ .tool-usage-name {
1094
+ font-weight: 600;
1095
+ color: var(--md-sys-color-on-surface, #0f172a);
1096
+ font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
1097
+ font-size: 10.5px;
1098
+ }
1099
+ .tool-usage-kind {
1100
+ font-size: 9.5px;
1101
+ padding: 1px 5px;
1102
+ border-radius: 3px;
1103
+ text-transform: uppercase;
1104
+ letter-spacing: 0.05em;
1105
+ font-weight: 600;
1106
+ }
1107
+ .tool-usage-kind.kind-read {
1108
+ color: var(--md-sys-color-on-tertiary, #ffffff);
1109
+ background: var(--md-sys-color-tertiary, #10b981);
1110
+ }
1111
+ .tool-usage-kind.kind-write {
1112
+ color: var(--md-sys-color-on-primary, #ffffff);
1113
+ background: var(--md-sys-color-primary, #1e293b);
1114
+ }
1115
+ .tool-usage-kind.kind-unknown {
1116
+ color: var(--md-sys-color-on-error, #ffffff);
1117
+ background: var(--md-sys-color-error, #b91c1c);
1118
+ }
1119
+ .tool-usage-args,
1120
+ .tool-usage-result {
1121
+ margin: 2px 0 0;
1122
+ padding: 4px 6px;
1123
+ background: var(--md-sys-color-surface-container-high, rgba(15, 23, 42, 0.04));
1124
+ border-radius: 3px;
1125
+ font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
1126
+ font-size: 10px;
1127
+ line-height: 1.4;
1128
+ white-space: pre-wrap;
1129
+ overflow-x: auto;
1130
+ color: var(--md-sys-color-on-surface, #0f172a);
1131
+ max-height: 180px;
1132
+ overflow-y: auto;
1133
+ }
1134
+ .tool-usage-args {
1135
+ border-left: 2px solid var(--md-sys-color-outline-variant, #cbd5e1);
1136
+ }
1137
+ .tool-usage-result {
1138
+ border-left: 2px solid var(--md-sys-color-outline, #94a3b8);
1139
+ }
1140
+
1141
+ /* ── Hover actions (assistant 메시지만, Material icon button) ── */
1142
+ .msg-actions {
1143
+ display: flex;
1144
+ gap: 2px;
1145
+ margin-top: 2px;
1146
+ padding: 0;
1147
+ opacity: 0;
1148
+ transition: opacity 0.15s;
1149
+ pointer-events: none;
1150
+ }
1151
+ .msg-wrap:hover .msg-actions,
1152
+ .msg-wrap:focus-within .msg-actions {
1153
+ opacity: 1;
1154
+ pointer-events: auto;
1155
+ }
1156
+ .msg-actions .msg-action {
1157
+ width: 22px;
1158
+ height: 22px;
1159
+ padding: 0;
1160
+ background: transparent;
1161
+ border: 0;
1162
+ border-radius: 5px;
1163
+ cursor: pointer;
1164
+ display: inline-flex;
1165
+ align-items: center;
1166
+ justify-content: center;
1167
+ color: var(--md-sys-color-outline, #94a3b8);
1168
+ transition:
1169
+ background 0.12s,
1170
+ color 0.12s;
1171
+ }
1172
+ .msg-actions .msg-action:hover:not(:disabled) {
1173
+ background: var(--md-sys-color-surface-container-high, #e2e8f0);
1174
+ color: var(--md-sys-color-on-surface, #0f172a);
1175
+ }
1176
+ .msg-actions .msg-action:disabled {
1177
+ opacity: 0.4;
1178
+ cursor: default;
1179
+ }
1180
+ .msg-actions .msg-action.confirmed {
1181
+ color: var(--md-sys-color-tertiary, #10b981);
1182
+ }
1183
+ .msg-actions .msg-action md-icon {
1184
+ --md-icon-size: 14px;
1185
+ }
1186
+
1187
+ /* ── Error banner ─────────────────────────────────── */
1188
+ .error {
1189
+ color: var(--md-sys-color-on-error-container, #b91c1c);
1190
+ padding: 7px 11px;
1191
+ background: var(--md-sys-color-error-container, #fef2f2);
1192
+ border: 1px solid var(--md-sys-color-error, #fee2e2);
1193
+ border-radius: 8px;
1194
+ margin: 0 16px 8px;
1195
+ font-size: 10.5px;
1196
+ line-height: 1.5;
1197
+ display: flex;
1198
+ align-items: center;
1199
+ justify-content: space-between;
1200
+ gap: 8px;
1201
+ }
1202
+ .error .error-text {
1203
+ flex: 1;
1204
+ }
1205
+ .error .retry-btn {
1206
+ flex-shrink: 0;
1207
+ padding: 3px 8px;
1208
+ font-size: 10px;
1209
+ font-family: inherit;
1210
+ letter-spacing: inherit;
1211
+ background: var(--md-sys-color-error, #b91c1c);
1212
+ color: var(--md-sys-color-on-error, #ffffff);
1213
+ border: 0;
1214
+ border-radius: 6px;
1215
+ cursor: pointer;
1216
+ transition: opacity 0.15s;
1217
+ width: auto;
1218
+ height: auto;
1219
+ }
1220
+ .error .retry-btn:hover {
1221
+ opacity: 0.88;
1222
+ }
1223
+
1224
+ /* ── Composer (actions + input 통합 영역) ──────────── */
1225
+ .composer {
1226
+ border-top: 1px solid var(--md-sys-color-outline-variant, #f1f5f9);
1227
+ background: var(--md-sys-color-surface, #ffffff);
1228
+ display: flex;
1229
+ flex-direction: column;
1230
+ }
1231
+
1232
+ /* ── Mini action row (메시지 시작 후) ──────────────── */
1233
+ .actions-row {
1234
+ display: flex;
1235
+ gap: 6px;
1236
+ padding: 6px 14px 4px;
1237
+ flex-wrap: wrap;
1238
+ align-items: center;
1239
+ }
1240
+ /* Material 3 assist-chip 톤 — height 24, font label-small */
1241
+ .action {
1242
+ display: inline-flex;
1243
+ align-items: center;
1244
+ justify-content: center;
1245
+ gap: 5px;
1246
+ height: 24px;
1247
+ padding: 0 10px;
1248
+ font-size: 11px;
1249
+ font-family: inherit;
1250
+ letter-spacing: inherit;
1251
+ line-height: 1;
1252
+ color: var(--md-sys-color-on-surface-variant, #475569);
1253
+ background: transparent;
1254
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1255
+ border-radius: 8px;
1256
+ cursor: pointer;
1257
+ transition:
1258
+ background 0.15s,
1259
+ border-color 0.15s,
1260
+ color 0.15s;
1261
+ width: auto;
1262
+ vertical-align: middle;
1263
+ }
1264
+ .action:hover {
1265
+ background: var(--md-sys-color-surface-container, #f8fafc);
1266
+ border-color: var(--md-sys-color-outline, #cbd5e1);
1267
+ color: var(--md-sys-color-on-surface, #0f172a);
1268
+ }
1269
+ .action:active {
1270
+ transform: scale(0.97);
1271
+ }
1272
+ .action md-icon {
1273
+ --md-icon-size: 14px;
1274
+ opacity: 0.7;
1275
+ }
1276
+
1277
+ /* ── Inline examples (action row 의 "예시" 토글 시) ──── */
1278
+ .inline-examples {
1279
+ padding: 4px 16px 0;
1280
+ display: flex;
1281
+ flex-direction: column;
1282
+ gap: 3px;
1283
+ }
1284
+ .inline-examples .ex-row {
1285
+ display: flex;
1286
+ flex-wrap: wrap;
1287
+ gap: 4px;
1288
+ }
1289
+ .inline-examples .ex-row .ex-label {
1290
+ font-size: 9px;
1291
+ color: var(--md-sys-color-outline, #94a3b8);
1292
+ letter-spacing: 0.05em;
1293
+ text-transform: uppercase;
1294
+ margin-right: 2px;
1295
+ align-self: center;
1296
+ font-weight: 600;
1297
+ }
1298
+ .inline-examples .ex {
1299
+ all: unset;
1300
+ cursor: pointer;
1301
+ padding: 2px 8px;
1302
+ font-size: 10px;
1303
+ color: var(--md-sys-color-on-surface, #0f172a);
1304
+ background: var(--md-sys-color-surface-container, #f8fafc);
1305
+ border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
1306
+ border-radius: 10px;
1307
+ transition: background 0.15s;
1308
+ }
1309
+ .inline-examples .ex:hover {
1310
+ background: var(--md-sys-color-surface-container-high, #f1f5f9);
1311
+ }
1312
+
1313
+ /* ── Input area ───────────────────────────────────── */
1314
+ /* border-top 은 .composer 가 담당 (actions-row 가 같은 wrapper 안) */
1315
+ .input-row {
1316
+ display: flex;
1317
+ gap: 8px;
1318
+ padding: 6px 14px 10px;
1319
+ background: transparent;
1320
+ align-items: flex-end;
1321
+ }
1322
+ /* 메시지 시작 전 — actions-row 가 없을 때는 input-row 가 직접 border-top */
1323
+ .composer.no-actions .input-row {
1324
+ padding-top: 10px;
1325
+ }
1326
+
1327
+ /* mention popup — textarea wrapper 가 relative 컨텍스트, popup 이 textarea
1328
+ 와 정확히 같은 폭/좌표 (좌우 끝 정렬). send 버튼 영역은 제외. */
1329
+ .textarea-wrap {
1330
+ flex: 1;
1331
+ position: relative;
1332
+ display: flex;
1333
+ min-width: 0;
1334
+ }
1335
+ .textarea-wrap textarea {
1336
+ flex: 1;
1337
+ width: 100%;
1338
+ }
1339
+ .mention-popup {
1340
+ bottom: calc(100% + 4px);
1341
+ left: 0;
1342
+ right: 0;
1343
+ }
1344
+
1345
+ textarea {
1346
+ flex: 1;
1347
+ padding: 8px 12px;
1348
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1349
+ border-radius: 12px;
1350
+ font: inherit;
1351
+ letter-spacing: inherit;
1352
+ resize: none;
1353
+ min-height: 36px;
1354
+ max-height: 140px;
1355
+ outline: none;
1356
+ transition:
1357
+ border-color 0.2s,
1358
+ box-shadow 0.2s;
1359
+ background: var(--md-sys-color-surface, #ffffff);
1360
+ color: var(--md-sys-color-on-surface, #0f172a);
1361
+ }
1362
+ textarea::placeholder {
1363
+ color: var(--md-sys-color-outline, #94a3b8);
1364
+ }
1365
+ textarea:focus {
1366
+ border-color: var(--md-sys-color-outline, #cbd5e1);
1367
+ box-shadow: 0 0 0 3px var(--md-sys-color-primary-container, rgba(15, 23, 42, 0.08));
1368
+ }
1369
+ textarea:disabled {
1370
+ background: var(--md-sys-color-surface-container, #f8fafc);
1371
+ color: var(--md-sys-color-outline, #94a3b8);
1372
+ cursor: not-allowed;
1373
+ }
1374
+
1375
+ /* ── Send button — ChatGPT 스타일 원형 ↑ (.input-row 안의 button 만) ─── */
1376
+ .input-row > button {
1377
+ width: 32px;
1378
+ height: 32px;
1379
+ padding: 0;
1380
+ background: var(--md-sys-color-primary, #1e293b);
1381
+ color: var(--md-sys-color-on-primary, #ffffff);
1382
+ border: 0;
1383
+ border-radius: 50%;
1384
+ cursor: pointer;
1385
+ display: flex;
1386
+ align-items: center;
1387
+ justify-content: center;
1388
+ transition:
1389
+ background 0.15s,
1390
+ transform 0.1s,
1391
+ opacity 0.15s;
1392
+ flex-shrink: 0;
1393
+ align-self: flex-end;
1394
+ margin-bottom: 2px;
1395
+ }
1396
+ .input-row > button:disabled {
1397
+ background: var(--md-sys-color-surface-container-highest, #e2e8f0);
1398
+ color: var(--md-sys-color-outline, #94a3b8);
1399
+ cursor: not-allowed;
1400
+ }
1401
+ .input-row > button:hover:not(:disabled) {
1402
+ opacity: 0.88;
1403
+ }
1404
+ .input-row > button:active:not(:disabled) {
1405
+ transform: scale(0.94);
1406
+ }
1407
+ .input-row > button md-icon {
1408
+ --md-icon-size: 18px;
1409
+ }
1410
+ .input-row > button .spinner {
1411
+ width: 12px;
1412
+ height: 12px;
1413
+ border: 1.8px solid currentColor;
1414
+ border-right-color: transparent;
1415
+ border-radius: 50%;
1416
+ animation: spin 0.7s linear infinite;
1417
+ }
1418
+ @keyframes spin {
1419
+ to {
1420
+ transform: rotate(360deg);
1421
+ }
1422
+ }
1423
+ `
1424
+ ]
1425
+
1426
+ connectedCallback() {
1427
+ super.connectedCallback()
1428
+ // 첫 마운트 시 sessionId 가 이미 set 돼있어도 loadHistory 는 첫 render 후 updated()
1429
+ // 가 처리 — 단일 진입점으로 일관 (이전엔 connectedCallback 과 updated 양쪽이 호출 →
1430
+ // 동시 fetch race 발생).
1431
+ }
1432
+
1433
+ updated(changed: Map<string, any>) {
1434
+ if (changed.has('sessionId')) {
1435
+ // sessionId 가 바뀌면 (set / unset / 다른 보드 전환) 이전 세션의 모든 클라이언트
1436
+ // 컨텍스트 정리 — lines / pickedMentions / errorMessage / input 모두 새지 않도록.
1437
+ // 보드 단위 격리 (ChatSession 1:1 boardId) 가 깨지는 회귀 방지.
1438
+ this.lines = []
1439
+ this.pickedMentions.clear()
1440
+ this.pickedUserMentions.clear()
1441
+ this.cachedUserCandidates = []
1442
+ this.errorMessage = undefined
1443
+ this.input = ''
1444
+ this.participants = []
1445
+ this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
1446
+ if (this.sessionId) {
1447
+ this.loadHistory()
1448
+ this._startPresence() // C4: roster 로드 + heartbeat 시작
1449
+ this._startMessageSubscription() // C4: 메시지 실시간 방송 구독
1450
+ } else {
1451
+ this._stopPresence()
1452
+ this._stopMessageSubscription()
1453
+ }
1454
+ }
1455
+ // 자동 스크롤
1456
+ const ms = this.renderRoot.querySelector('.messages') as HTMLElement | null
1457
+ if (ms) ms.scrollTop = ms.scrollHeight
1458
+ }
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
+
1573
+ private async loadHistory() {
1574
+ if (!this.sessionId) return
1575
+ try {
1576
+ const result = await client.query({
1577
+ query: CHAT_MESSAGES_QUERY,
1578
+ variables: { sessionId: this.sessionId, limit: 200, offset: 0 },
1579
+ fetchPolicy: 'network-only'
1580
+ })
1581
+ const msgs = result.data?.chatMessages ?? []
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))
1585
+ } catch (e: any) {
1586
+ this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`
1587
+ }
1588
+ }
1589
+
1590
+ render() {
1591
+ const hasMessages = this.lines.length > 0
1592
+ return html`
1593
+ ${this.toastMessage
1594
+ ? html`<div class="toast show">${this.toastMessage}</div>`
1595
+ : nothing}
1596
+ ${this.renderSessionTabs()}
1597
+ ${this.renderRoster()}
1598
+ <div class="messages" @click=${this.onBubbleClick}>
1599
+ ${!hasMessages ? this.renderEmpty() : nothing}
1600
+ ${(() => {
1601
+ const myId = this._myUserId
1602
+ return this.lines.map((line, idx) => {
1603
+ const prev = this.lines[idx - 1]
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
1609
+ const content = (line.content ?? '').trim()
1610
+ const isMarkdown = line.role !== 'system' && content.length > 0
1611
+ const showActions = line.role !== 'system' && !line.pending && content.length > 0
1612
+ return html`
1613
+ ${showLabel
1614
+ ? html`<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
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' : ''}">
1620
+ ${line.pending
1621
+ ? html`<span class="typing-dots"><span></span><span></span><span></span></span>`
1622
+ : isMarkdown
1623
+ ? html`<div class="md-body">${unsafeHTML(renderMarkdown(content))}</div>`
1624
+ : content}
1625
+ ${line.patchId
1626
+ ? html`
1627
+ <div class="summary ${this.revertedPatchIds.has(line.patchId) ? 'reverted' : ''}">
1628
+ <md-icon>check_circle</md-icon>
1629
+ <span class="summary-text">
1630
+ ${this.revertedPatchIds.has(line.patchId)
1631
+ ? i18next.t('board-ai.text.reverted')
1632
+ : i18next.t('board-ai.text.applied-changes')}
1633
+ </span>
1634
+ <button
1635
+ class="revert-btn"
1636
+ ?disabled=${this.revertedPatchIds.has(line.patchId)}
1637
+ @click=${() => line.patchId && this.onRevertClick(line.patchId)}>
1638
+ ${i18next.t('board-ai.button.revert')}
1639
+ </button>
1640
+ </div>
1641
+ `
1642
+ : nothing}
1643
+ ${line.followUp
1644
+ ? html`<div class="followup">${unsafeHTML(renderMarkdown(line.followUp.trim()))}</div>`
1645
+ : nothing}
1646
+ ${line.toolUsages && line.toolUsages.length > 0
1647
+ ? this.renderToolUsages(idx, line)
1648
+ : nothing}
1649
+ </div>
1650
+ ${showActions ? this.renderMessageActions(idx, line, content) : nothing}
1651
+ </div>
1652
+ `
1653
+ })
1654
+ })()}
1655
+ </div>
1656
+ ${this.errorMessage
1657
+ ? html`
1658
+ <div class="error">
1659
+ <span class="error-text">${this.errorMessage}</span>
1660
+ ${this.lastFailedInput
1661
+ ? html`
1662
+ <button class="retry-btn" @click=${this.retryLastSend}>
1663
+ ${i18next.t('board-ai.button.retry')}
1664
+ </button>
1665
+ `
1666
+ : nothing}
1667
+ </div>
1668
+ `
1669
+ : nothing}
1670
+ <div class="composer ${hasMessages ? '' : 'no-actions'}">
1671
+ ${hasMessages
1672
+ ? html`
1673
+ <div class="actions-row">
1674
+ <button
1675
+ class="action"
1676
+ @click=${() => (this.examplesOpen = !this.examplesOpen)}
1677
+ title=${i18next.t('board-ai.text.show-examples-tooltip')}>
1678
+ <md-icon>${this.examplesOpen ? 'close' : 'lightbulb'}</md-icon>
1679
+ ${this.examplesOpen
1680
+ ? i18next.t('board-ai.button.close')
1681
+ : i18next.t('board-ai.button.examples')}
1682
+ </button>
1683
+ </div>
1684
+ ${this.examplesOpen ? this.renderInlineExamples() : nothing}
1685
+ `
1686
+ : nothing}
1687
+ <div class="input-row">
1688
+ <div class="textarea-wrap">
1689
+ <textarea
1690
+ .value=${this.input}
1691
+ @input=${(e: Event) => this.onTextareaInput(e)}
1692
+ @keydown=${(e: KeyboardEvent) => this.onKeyDown(e)}
1693
+ @blur=${() => {
1694
+ // 포커스를 popup item 으로 옮기는 click 보다 blur 가 먼저 — popup item 의
1695
+ // mousedown.preventDefault() 가 처리하지만 안전망으로 짧은 지연 후 닫기.
1696
+ setTimeout(() => this.closeMentionPopup(), 150)
1697
+ }}
1698
+ ?disabled=${this.busy}
1699
+ rows="1"
1700
+ placeholder=${this.placeholder ?? i18next.t('board-ai.text.input-placeholder')}></textarea>
1701
+ ${this.mentionOpen
1702
+ ? html`
1703
+ <ox-mention-popup
1704
+ class="mention-popup"
1705
+ .results=${this.mentionResults}
1706
+ .activeIndex=${this.mentionActiveIndex}
1707
+ .overflow=${this.mentionOverflow}
1708
+ @mention-active-change=${(e: CustomEvent) => {
1709
+ // 마우스 hover — keyboard nav 와 동일하게 select + centerTo
1710
+ const idx = e.detail?.index
1711
+ if (typeof idx === 'number' && idx >= 0 && idx < this.mentionResults.length) {
1712
+ this.mentionActiveIndex = idx
1713
+ this.previewActiveCandidate()
1714
+ }
1715
+ }}
1716
+ @mention-select=${(e: CustomEvent) =>
1717
+ this.applyMention(e.detail.candidate)}></ox-mention-popup>
1718
+ `
1719
+ : nothing}
1720
+ </div>
1721
+ <button
1722
+ ?disabled=${this.busy || !this.input.trim()}
1723
+ @click=${this.send}
1724
+ title=${i18next.t('board-ai.text.send-tooltip')}>
1725
+ ${this.busy
1726
+ ? html`<span class="spinner"></span>`
1727
+ : html`<md-icon>arrow_upward</md-icon>`}
1728
+ </button>
1729
+ </div>
1730
+ </div>
1731
+ `
1732
+ }
1733
+
1734
+ private onKeyDown(e: KeyboardEvent) {
1735
+ // Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
1736
+ if (this.mentionOpen) {
1737
+ if (e.key === 'ArrowDown') {
1738
+ e.preventDefault()
1739
+ if (this.mentionResults.length > 0) {
1740
+ this.mentionActiveIndex =
1741
+ (this.mentionActiveIndex + 1) % this.mentionResults.length
1742
+ this.previewActiveCandidate()
1743
+ }
1744
+ return
1745
+ }
1746
+ if (e.key === 'ArrowUp') {
1747
+ e.preventDefault()
1748
+ if (this.mentionResults.length > 0) {
1749
+ this.mentionActiveIndex =
1750
+ (this.mentionActiveIndex - 1 + this.mentionResults.length) %
1751
+ this.mentionResults.length
1752
+ this.previewActiveCandidate()
1753
+ }
1754
+ return
1755
+ }
1756
+ if (e.key === 'Enter' || e.key === 'Tab') {
1757
+ if (this.mentionResults.length > 0) {
1758
+ e.preventDefault()
1759
+ this.applyMention(this.mentionResults[this.mentionActiveIndex].candidate)
1760
+ return
1761
+ }
1762
+ // 매칭 0개면 popup 닫고 일반 동작 fall through
1763
+ this.closeMentionPopup()
1764
+ }
1765
+ if (e.key === 'Escape') {
1766
+ e.preventDefault()
1767
+ this.closeMentionPopup()
1768
+ return
1769
+ }
1770
+ }
1771
+ if (e.key === 'Enter' && !e.shiftKey && !this.busy) {
1772
+ e.preventDefault()
1773
+ this.send()
1774
+ }
1775
+ }
1776
+
1777
+ /** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
1778
+ * big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
1779
+ private onTextareaInput(e: Event) {
1780
+ const ta = e.target as HTMLTextAreaElement
1781
+ this.input = ta.value
1782
+ if (this.inputDebounceTimer) clearTimeout(this.inputDebounceTimer)
1783
+ this.inputDebounceTimer = setTimeout(() => {
1784
+ this.refreshMentionPopup(ta)
1785
+ }, 150)
1786
+ }
1787
+
1788
+ /** 현재 cursor 위치를 보고 mention popup 활성/비활성 + 후보 갱신.
1789
+ * `#` (컴포넌트) / `@` (카탈로그) / `/` (slash 템플릿) 한 번에 하나만 활성. */
1790
+ private refreshMentionPopup(ta: HTMLTextAreaElement) {
1791
+ const cursor = ta.selectionStart ?? ta.value.length
1792
+
1793
+ const triggers: Array<'#' | '@' | '/'> = ['#', '@', '/']
1794
+ let chosen: { hashPos: number; query: string; trigger: '#' | '@' | '/' } | null =
1795
+ null
1796
+ for (const t of triggers) {
1797
+ const ctx = extractMentionContext(ta.value, cursor, t)
1798
+ if (!ctx) continue
1799
+ if (!chosen || ctx.hashPos > chosen.hashPos) {
1800
+ chosen = { ...ctx, trigger: t }
1801
+ }
1802
+ }
1803
+
1804
+ if (!chosen) {
1805
+ if (this.mentionOpen) this.closeMentionPopup()
1806
+ return
1807
+ }
1808
+
1809
+ const wasOpen = this.mentionOpen
1810
+ const cap = 50
1811
+ let results: FilterResult[]
1812
+ if (chosen.trigger === '#' && this.searchProvider && chosen.query) {
1813
+ // 호스트의 SceneSearchEngine 위임 — F-Finder 와 동일 검색 룰. cap 만 chat 측에서 적용.
1814
+ results = this.searchProvider(chosen.query).slice(0, cap)
1815
+ } else if (chosen.trigger === '@') {
1816
+ // `@` = 도메인 사용자 멘션. host 가 userProvider set 했으면 query → users 비동기 fetch.
1817
+ // userProvider 미설정 / 결과 없음 → 빈 popup (멀티유저 미사용 환경).
1818
+ // async 라 즉시 반환은 빈 결과; await 후 다시 set 하는 패턴.
1819
+ results = this.cachedUserCandidates
1820
+ ? filterMentionCandidates(this.cachedUserCandidates, chosen.query, cap)
1821
+ : []
1822
+ // 비동기 갱신 — 결과 도착하면 popup 다시 갱신
1823
+ this.refreshUserCandidatesAsync(chosen.query, chosen.hashPos)
1824
+ } else {
1825
+ let all: MentionCandidate[]
1826
+ if (chosen.trigger === '#') {
1827
+ // 빈 query 의 # 또는 searchProvider 미설정 — 로컬 enumeration 으로 전체 후보 표시
1828
+ const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
1829
+ all = buildMentionCandidates(liveBoard?.components ?? [])
1830
+ } else {
1831
+ // `/` = 카탈로그 + slash 템플릿 통합 (Notion `/` 패턴). 둘 다 "삽입 가능 항목".
1832
+ all = [...this.cachedCatalogCandidates, ...this.cachedSlashCandidates]
1833
+ }
1834
+ results = filterMentionCandidates(all, chosen.query, cap)
1835
+ }
1836
+ // overflow 계산 — 전체 매칭 중 cap 으로 잘려나간 수 (popup 'N more' 표기)
1837
+ // (cap 잘라서 반환하므로, 전체 카운트는 자유 검색 시 알기 어려움.
1838
+ // 엄격한 overflow 추적 대신 results.length === cap 이면 추정 표시)
1839
+ const overflow = results.length === cap ? 0 : 0
1840
+ this.mentionResults = results
1841
+ this.mentionOverflow = overflow
1842
+ this.mentionActiveIndex = 0
1843
+ this.mentionHashPos = chosen.hashPos
1844
+ this.mentionTrigger = chosen.trigger
1845
+ this.mentionOpen = true
1846
+
1847
+ // 첫 open 시 — 사용자 selection 스냅샷 (취소 시 원복용)
1848
+ if (!wasOpen && chosen.trigger === '#') {
1849
+ this.originalSelectedRefids = (this.selectedRefids ?? []).slice()
1850
+ }
1851
+ // 매칭된 후보 outline 만 갱신 — 카메라/선택 변경은 commit (Enter/click) 시점에만.
1852
+ this.highlightMatchedCandidates()
1853
+ }
1854
+
1855
+ /** `@` query 에 대한 userProvider 비동기 호출 — 응답 도착 시 popup 결과 갱신.
1856
+ * race 방지: token 으로 최신 호출 식별 (늦게 도착한 응답 무시). */
1857
+ private async refreshUserCandidatesAsync(query: string, hashPos: number) {
1858
+ if (!this.userProvider) return
1859
+ const token = ++this.userFetchToken
1860
+ try {
1861
+ const users = await Promise.resolve(this.userProvider(query))
1862
+ if (token !== this.userFetchToken) return // 더 최신 호출이 있음
1863
+ // popup 이 닫혔거나 trigger / hashPos 가 바뀌면 무시
1864
+ if (!this.mentionOpen || this.mentionTrigger !== '@' || this.mentionHashPos !== hashPos) {
1865
+ return
1866
+ }
1867
+ this.cachedUserCandidates = buildUserCandidates(users)
1868
+ this.mentionResults = filterMentionCandidates(
1869
+ this.cachedUserCandidates,
1870
+ query,
1871
+ 50
1872
+ )
1873
+ this.mentionActiveIndex = 0
1874
+ } catch (e) {
1875
+ // 조용히 무시 — 사용자 입력 흐름 방해 X. 디버그 시 console 확인 가능.
1876
+ console.warn('[board-ai-chat] userProvider failed:', e)
1877
+ }
1878
+ }
1879
+
1880
+ /** popup 후보들 outline highlight — 카메라/선택 변경 없음.
1881
+ * 타이핑 직후 (refreshMentionPopup) 에서 호출 — 사용자가 active 를 명시 이동하지
1882
+ * 않은 상태이므로 임의의 첫 항목을 선택하거나 카메라 이동시키지 않음.
1883
+ * # trigger 일 때만 활성. */
1884
+ private highlightMatchedCandidates() {
1885
+ if (this.mentionTrigger !== '#') return
1886
+ const allRefids: number[] = []
1887
+ for (const r of this.mentionResults) {
1888
+ if (typeof r.candidate.refid === 'number') allRefids.push(r.candidate.refid)
1889
+ }
1890
+ this.dispatchActions([{ action: 'highlightComponents', refids: allRefids }])
1891
+ }
1892
+
1893
+ /** active 후보 live preview — 사용자가 ↑↓ 키 / popup 마우스 hover 로 명시적으로
1894
+ * active 를 옮긴 시점에 호출. F-Finder 동급 3-action: highlight + select + centerTo.
1895
+ * centerTo 는 150ms 디바운스 — 빠른 nav 시 카메라 애니메이션 race 방지.
1896
+ * # trigger 일 때만 활성. */
1897
+ private previewActiveCandidate() {
1898
+ if (this.mentionTrigger !== '#') return
1899
+ const allRefids: number[] = []
1900
+ for (const r of this.mentionResults) {
1901
+ if (typeof r.candidate.refid === 'number') allRefids.push(r.candidate.refid)
1902
+ }
1903
+ const active = this.mentionResults[this.mentionActiveIndex]
1904
+ const activeRefid =
1905
+ active && typeof active.candidate.refid === 'number'
1906
+ ? active.candidate.refid
1907
+ : undefined
1908
+
1909
+ const actions: any[] = [{ action: 'highlightComponents', refids: allRefids }]
1910
+ if (typeof activeRefid === 'number') {
1911
+ actions.push({ action: 'selectComponents', refids: [activeRefid] })
1912
+ }
1913
+ this.dispatchActions(actions)
1914
+
1915
+ if (this.centerDebounceTimer) clearTimeout(this.centerDebounceTimer)
1916
+ if (typeof activeRefid === 'number') {
1917
+ this.centerDebounceTimer = setTimeout(() => {
1918
+ this.dispatchActions([
1919
+ { action: 'centerToComponent', refid: activeRefid, animated: true }
1920
+ ])
1921
+ }, 150)
1922
+ }
1923
+ }
1924
+
1925
+ private dispatchActions(actions: any[]) {
1926
+ this.dispatchEvent(
1927
+ new CustomEvent('board-action-execute', {
1928
+ detail: { actions, sessionId: this.sessionId },
1929
+ bubbles: true,
1930
+ composed: true
1931
+ })
1932
+ )
1933
+ }
1934
+
1935
+ /** 메시지 버블의 `<span class="mention">#token</span>` 클릭 — 컴포넌트로 select + centerTo.
1936
+ * resolve 룰은 pure helper `resolveMentionToken` 에 위임 (단위 테스트 가능). */
1937
+ private onBubbleClick = (e: Event) => {
1938
+ const target = e.target as HTMLElement | null
1939
+ if (!target?.classList?.contains('mention')) return
1940
+ e.preventDefault()
1941
+ const text = (target.textContent || '').trim()
1942
+ const token = text.startsWith('#') ? text.slice(1) : text
1943
+ if (!token) return
1944
+
1945
+ const refid = resolveMentionToken(token, {
1946
+ persistedRefid: target.getAttribute('data-refid'),
1947
+ searchProvider: this.searchProvider,
1948
+ board: this.boardProvider ? this.boardProvider() : this.currentBoard
1949
+ })
1950
+
1951
+ if (typeof refid === 'number') {
1952
+ this.dispatchActions([
1953
+ { action: 'selectComponents', refids: [refid] },
1954
+ { action: 'centerToComponent', refid, animated: true }
1955
+ ])
1956
+ } else {
1957
+ this.showToast(
1958
+ i18next.t('board-ai.text.mention-not-found', {
1959
+ token,
1960
+ defaultValue: `#${token} 을(를) 찾을 수 없습니다`
1961
+ })
1962
+ )
1963
+ }
1964
+ }
1965
+
1966
+ /** popup 닫기. commit=false (취소) 면 # 의 경우:
1967
+ * - highlight 정리 (highlightComponents [])
1968
+ * - 원래 selection 으로 원복
1969
+ * commit=true (확정) 면 현재 preview 상태 유지 + highlight 만 정리. */
1970
+ private closeMentionPopup(commit = false) {
1971
+ if (!this.mentionOpen) return
1972
+ const wasHashTrigger = this.mentionTrigger === '#'
1973
+ this.mentionOpen = false
1974
+ this.mentionResults = []
1975
+ this.mentionOverflow = 0
1976
+ this.mentionActiveIndex = 0
1977
+ this.mentionHashPos = -1
1978
+
1979
+ if (wasHashTrigger) {
1980
+ // popup 닫힘 → highlight 항상 정리 (확정/취소 모두)
1981
+ const cleanupActions: any[] = [{ action: 'highlightComponents', refids: [] }]
1982
+ if (!commit) {
1983
+ // 취소 — 원래 selection 으로 원복
1984
+ cleanupActions.push({
1985
+ action: 'selectComponents',
1986
+ refids: this.originalSelectedRefids
1987
+ })
1988
+ }
1989
+ this.dispatchActions(cleanupActions)
1990
+ }
1991
+ }
1992
+
1993
+ /** 후보 1개를 textarea 에 적용 + popup 닫고 cursor 재배치. */
1994
+ private applyMention(candidate: MentionCandidate) {
1995
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
1996
+ if (!ta || this.mentionHashPos < 0) {
1997
+ this.closeMentionPopup()
1998
+ return
1999
+ }
2000
+ const cursor = ta.selectionStart ?? ta.value.length
2001
+ const r = applyMentionSelection(
2002
+ ta.value,
2003
+ cursor,
2004
+ this.mentionHashPos,
2005
+ candidate,
2006
+ this.mentionTrigger
2007
+ )
2008
+ this.input = r.text
2009
+ // # trigger + refid 보유 후보 — commit 시점에만 select + centerTo (사용자 의도 확정 순간).
2010
+ // 동시에 picks 로 기록: textarea 에는 깨끗한 #label, server 에는 정확한 refid 전달.
2011
+ if (
2012
+ this.mentionTrigger === '#' &&
2013
+ typeof candidate.refid === 'number'
2014
+ ) {
2015
+ this.pickedMentions.set(candidate.label, candidate.refid)
2016
+ this.dispatchActions([
2017
+ { action: 'selectComponents', refids: [candidate.refid] },
2018
+ { action: 'centerToComponent', refid: candidate.refid, animated: true }
2019
+ ])
2020
+ } else if (
2021
+ this.mentionTrigger === '@' &&
2022
+ typeof candidate.userId === 'string' &&
2023
+ candidate.userId.length > 0
2024
+ ) {
2025
+ // @user 멘션 — userId 박제. 보드 액션은 발사 안 함 (사용자는 컴포넌트 아님).
2026
+ this.pickedUserMentions.set(candidate.label, candidate.userId)
2027
+ }
2028
+ this.closeMentionPopup(true) // commit — preview selection 유지, highlight 만 정리
2029
+ requestAnimationFrame(() => {
2030
+ const ta2 = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
2031
+ ta2?.focus()
2032
+ ta2?.setSelectionRange(r.cursorPos, r.cursorPos)
2033
+ })
2034
+ }
2035
+
2036
+ // ── Empty state with onboarding guide ──────────────────────────
2037
+
2038
+ /** 예시 그룹 — i18n 키. localize mixin 이 언어 변경 시 자동 재렌더. */
2039
+ private static readonly EXAMPLE_GROUPS: Array<{
2040
+ labelKey: string
2041
+ itemKeys: string[]
2042
+ }> = [
2043
+ {
2044
+ labelKey: 'board-ai.label.create',
2045
+ itemKeys: [
2046
+ 'board-ai.example.create-monitoring-dashboard',
2047
+ 'board-ai.example.create-welcome-screen'
2048
+ ]
2049
+ },
2050
+ {
2051
+ labelKey: 'board-ai.label.edit',
2052
+ itemKeys: [
2053
+ 'board-ai.example.edit-align-distribute',
2054
+ 'board-ai.example.edit-resize-board'
2055
+ ]
2056
+ },
2057
+ {
2058
+ labelKey: 'board-ai.label.style',
2059
+ itemKeys: [
2060
+ 'board-ai.example.style-dark-mode',
2061
+ 'board-ai.example.style-rounded-shadow'
2062
+ ]
2063
+ }
2064
+ ]
2065
+
2066
+ /** 보드의 세션 탭 — 다중 세션 활성화 시 패널 상단. host 가 sessions 비워두면 표시 X.
2067
+ * 레이블은 session.name 우선, 없으면 fallback "세션 N". */
2068
+ private renderSessionTabs() {
2069
+ if (!this.sessions || this.sessions.length === 0) return nothing
2070
+ return html`
2071
+ <div class="session-tabs" role="tablist">
2072
+ ${this.sessions.map((s, i) => {
2073
+ const isActive = s.id === this.sessionId
2074
+ const fallback = i18next.t('board-ai.text.session-tab-label', {
2075
+ n: i + 1,
2076
+ defaultValue: `세션 ${i + 1}`
2077
+ })
2078
+ const label = s.name?.trim() || fallback
2079
+ const tooltip = s.createdAt
2080
+ ? `${label} · ${new Date(s.createdAt).toLocaleString()}`
2081
+ : label
2082
+ return html`
2083
+ <button
2084
+ class="session-tab ${isActive ? 'active' : ''}"
2085
+ role="tab"
2086
+ aria-selected=${isActive ? 'true' : 'false'}
2087
+ title=${tooltip}
2088
+ @click=${() => this._onSessionTabClick(s.id)}>
2089
+ ${label}
2090
+ </button>
2091
+ `
2092
+ })}
2093
+ <button
2094
+ class="session-tab-new"
2095
+ title=${i18next.t('board-ai.button.new-chat', {
2096
+ defaultValue: '새 대화'
2097
+ })}
2098
+ @click=${this._onSessionCreateClick}>
2099
+ <md-icon>add</md-icon>
2100
+ <span>${i18next.t('board-ai.button.new-chat', { defaultValue: '새 대화' })}</span>
2101
+ </button>
2102
+ </div>
2103
+ `
2104
+ }
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
+
2128
+ private _onSessionTabClick(sessionId: string) {
2129
+ if (sessionId === this.sessionId) return
2130
+ this.dispatchEvent(
2131
+ new CustomEvent('session-switch', {
2132
+ detail: { sessionId },
2133
+ bubbles: true,
2134
+ composed: true
2135
+ })
2136
+ )
2137
+ }
2138
+
2139
+ private _onSessionCreateClick = () => {
2140
+ this.dispatchEvent(
2141
+ new CustomEvent('session-create', {
2142
+ bubbles: true,
2143
+ composed: true
2144
+ })
2145
+ )
2146
+ }
2147
+
2148
+ private renderEmpty() {
2149
+ return html`
2150
+ <div class="empty">
2151
+ <div class="header">
2152
+ <md-icon class="icon">auto_awesome</md-icon>
2153
+ <div class="title">${i18next.t('board-ai.text.empty-title')}</div>
2154
+ <div class="subtitle">${i18next.t('board-ai.text.empty-subtitle')}</div>
2155
+ </div>
2156
+
2157
+ <div class="trigger-row">
2158
+ <div class="trigger-row-label">${i18next.t('board-ai.text.triggers-heading')}</div>
2159
+ <div class="trigger-chips">
2160
+ <button
2161
+ class="trigger-chip"
2162
+ title=${i18next.t('board-ai.text.trigger-component-tooltip')}
2163
+ @click=${() => this.insertTrigger('#')}>
2164
+ <span class="trigger-char">#</span>
2165
+ <span class="trigger-desc">${i18next.t('board-ai.label.trigger-component')}</span>
2166
+ </button>
2167
+ <button
2168
+ class="trigger-chip"
2169
+ title=${i18next.t('board-ai.text.trigger-user-tooltip')}
2170
+ @click=${() => this.insertTrigger('@')}>
2171
+ <span class="trigger-char">@</span>
2172
+ <span class="trigger-desc">${i18next.t('board-ai.label.trigger-user')}</span>
2173
+ </button>
2174
+ <button
2175
+ class="trigger-chip"
2176
+ title=${i18next.t('board-ai.text.trigger-insert-tooltip')}
2177
+ @click=${() => this.insertTrigger('/')}>
2178
+ <span class="trigger-char">/</span>
2179
+ <span class="trigger-desc">${i18next.t('board-ai.label.trigger-insert')}</span>
2180
+ </button>
2181
+ </div>
2182
+ </div>
2183
+
2184
+ ${OxBoardAIChat.EXAMPLE_GROUPS.map(
2185
+ group => html`
2186
+ <div class="group">
2187
+ <div class="group-label">${i18next.t(group.labelKey)}</div>
2188
+ ${group.itemKeys.map(key => {
2189
+ const text = i18next.t(key)
2190
+ return html`
2191
+ <button class="example" @click=${() => this.useExample(text)}>
2192
+ ${text}<span class="arrow">→</span>
2193
+ </button>
2194
+ `
2195
+ })}
2196
+ </div>
2197
+ `
2198
+ )}
2199
+
2200
+ <div class="footer">
2201
+ <span class="badge">${i18next.t('board-ai.label.korean-supported')}</span>
2202
+ <span class="badge">${i18next.t('board-ai.label.multi-command')}</span>
2203
+ <span class="badge">${i18next.t('board-ai.label.review-able')}</span>
2204
+ <br />
2205
+ ${i18next.t('board-ai.text.empty-footer-notice')}
2206
+ </div>
2207
+ </div>
2208
+ `
2209
+ }
2210
+
2211
+ /** trigger chip 클릭 — textarea 에 trigger 문자 삽입 + focus + popup 자동 활성. */
2212
+ private insertTrigger(trigger: '#' | '@' | '/') {
2213
+ // 입력 끝에 공백이 없으면 공백 추가 후 trigger — 자연스러운 위치
2214
+ const needsSpace = this.input.length > 0 && !/\s$/.test(this.input)
2215
+ this.input = this.input + (needsSpace ? ' ' : '') + trigger
2216
+ requestAnimationFrame(() => {
2217
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
2218
+ if (!ta) return
2219
+ ta.focus()
2220
+ ta.setSelectionRange(ta.value.length, ta.value.length)
2221
+ // popup refresh — onTextareaInput 이 디바운스 후 호출되지만 직접 호출로 즉각 반영
2222
+ this.refreshMentionPopup(ta)
2223
+ })
2224
+ }
2225
+
2226
+ /** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
2227
+ private useExample(text: string) {
2228
+ this.input = text
2229
+ this.examplesOpen = false
2230
+ requestAnimationFrame(() => {
2231
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
2232
+ ta?.focus()
2233
+ ta?.setSelectionRange(text.length, text.length)
2234
+ })
2235
+ }
2236
+
2237
+ // ── Mini actions ───────────────────────────────────────────────
2238
+
2239
+ private renderInlineExamples() {
2240
+ return html`
2241
+ <div class="inline-examples">
2242
+ ${OxBoardAIChat.EXAMPLE_GROUPS.map(
2243
+ group => html`
2244
+ <div class="ex-row">
2245
+ <span class="ex-label">${i18next.t(group.labelKey)}</span>
2246
+ ${group.itemKeys.map(key => {
2247
+ const t = i18next.t(key)
2248
+ return html`<button class="ex" @click=${() => this.useExample(t)}>${t}</button>`
2249
+ })}
2250
+ </div>
2251
+ `
2252
+ )}
2253
+ </div>
2254
+ `
2255
+ }
2256
+
2257
+ // ── Retry (에러 발생 시) ───────────────────────────────────────
2258
+
2259
+ private async retryLastSend() {
2260
+ if (!this.lastFailedInput) return
2261
+ this.input = this.lastFailedInput
2262
+ this.errorMessage = undefined
2263
+ this.lastFailedInput = undefined
2264
+ await this.send()
2265
+ }
2266
+
2267
+ // ── Per-message hover action row ───────────────────────────────
2268
+
2269
+ /**
2270
+ * 메시지별 hover 액션 — 텍스트 chip 으로 명확.
2271
+ * user : 액션 row 자체 X (자기 메시지에 시각 노이즈 X — 편집은 input 위 "새 대화" 흐름 또는 마우스 더블클릭)
2272
+ * assistant : 복사 + 재생성 + (patch 적용된 경우만) 되돌리기
2273
+ * system : 없음
2274
+ */
2275
+ private renderMessageActions(idx: number, line: ChatLine, content: string) {
2276
+ if (line.role !== 'assistant') return nothing
2277
+
2278
+ const isCopied = this.copiedIdx === idx
2279
+ // revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
2280
+ return html`
2281
+ <div class="msg-actions">
2282
+ <button
2283
+ class="msg-action ${isCopied ? 'confirmed' : ''}"
2284
+ title=${isCopied
2285
+ ? i18next.t('board-ai.text.copied')
2286
+ : i18next.t('board-ai.button.copy')}
2287
+ @click=${() => this.copyMessage(idx, content)}>
2288
+ <md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
2289
+ </button>
2290
+ <button
2291
+ class="msg-action"
2292
+ ?disabled=${this.busy}
2293
+ title=${i18next.t('board-ai.button.regenerate')}
2294
+ @click=${() => this.regenerateAssistant(idx)}>
2295
+ <md-icon>refresh</md-icon>
2296
+ </button>
2297
+ </div>
2298
+ `
2299
+ }
2300
+
2301
+ /**
2302
+ * "AI 가 이런 도구를 사용했습니다" fold-able 박스.
2303
+ *
2304
+ * - 기본 닫힘 (toolUsagesOpen=false). 헤더 클릭 시 toggle.
2305
+ * - 펼쳤을 때 도구 별로 name + arguments + result + read/write 색상 배지.
2306
+ * - 디버그 / 신뢰도 향상용 — 사용자가 "왜 이렇게 답했지" 의문 가질 때 즉시 검증.
2307
+ */
2308
+ private renderToolUsages(idx: number, line: ChatLine) {
2309
+ const usages = line.toolUsages
2310
+ if (!usages || usages.length === 0) return nothing
2311
+ const open = !!line.toolUsagesOpen
2312
+ const readCount = usages.filter(u => u.kind === 'read').length
2313
+ const writeCount = usages.filter(u => u.kind === 'write').length
2314
+ const toolsLabel = i18next.t('board-ai.text.tools-used', { defaultValue: '도구 사용' })
2315
+ return html`
2316
+ <div class="tool-usages ${open ? 'open' : ''}">
2317
+ <button
2318
+ class="tool-usages-header"
2319
+ aria-expanded=${open}
2320
+ @click=${() => this.toggleToolUsages(idx)}>
2321
+ <md-icon class="tool-usages-icon">${open ? 'expand_less' : 'build'}</md-icon>
2322
+ <span class="tool-usages-summary">
2323
+ <strong>${usages.length}</strong> ${toolsLabel}
2324
+ <span class="tool-usages-counts">· read ${readCount} · write ${writeCount}</span>
2325
+ </span>
2326
+ <md-icon class="tool-usages-chevron">${open ? 'expand_less' : 'expand_more'}</md-icon>
2327
+ </button>
2328
+ ${open
2329
+ ? html`
2330
+ <ol class="tool-usages-list">
2331
+ ${usages.map(
2332
+ (u, i) => html`
2333
+ <li class="tool-usage-item kind-${u.kind}">
2334
+ <div class="tool-usage-head">
2335
+ <span class="tool-usage-step">${i + 1}.</span>
2336
+ <span class="tool-usage-name">${u.name}</span>
2337
+ <span class="tool-usage-kind kind-${u.kind}">${u.kind}</span>
2338
+ </div>
2339
+ ${u.arguments && Object.keys(u.arguments).length > 0
2340
+ ? html`<pre class="tool-usage-args">${this.formatJson(u.arguments)}</pre>`
2341
+ : nothing}
2342
+ <pre class="tool-usage-result">${this.formatJson(u.result)}</pre>
2343
+ </li>
2344
+ `
2345
+ )}
2346
+ </ol>
2347
+ `
2348
+ : nothing}
2349
+ </div>
2350
+ `
2351
+ }
2352
+
2353
+ private toggleToolUsages(idx: number) {
2354
+ const next = [...this.lines]
2355
+ const line = next[idx]
2356
+ if (!line) return
2357
+ next[idx] = { ...line, toolUsagesOpen: !line.toolUsagesOpen }
2358
+ this.lines = next
2359
+ }
2360
+
2361
+ private formatJson(value: any): string {
2362
+ try {
2363
+ return JSON.stringify(value, null, 2)
2364
+ } catch {
2365
+ return String(value)
2366
+ }
2367
+ }
2368
+
2369
+ // ── Per-message actions ────────────────────────────────────────
2370
+
2371
+ private async copyMessage(idx: number, content: string) {
2372
+ try {
2373
+ // 클립보드에는 marker 제거된 깨끗한 텍스트
2374
+ await navigator.clipboard.writeText(stripMentionRefids(content))
2375
+ this.copiedIdx = idx
2376
+ this.showToast(i18next.t('board-ai.text.copied'))
2377
+ setTimeout(() => {
2378
+ if (this.copiedIdx === idx) this.copiedIdx = undefined
2379
+ }, 1500)
2380
+ } catch {
2381
+ // clipboard 권한 없거나 비-secure context — 조용히 무시
2382
+ }
2383
+ }
2384
+
2385
+ private showToast(message: string) {
2386
+ this.toastMessage = message
2387
+ setTimeout(() => {
2388
+ if (this.toastMessage === message) this.toastMessage = undefined
2389
+ }, 1500)
2390
+ }
2391
+
2392
+ /**
2393
+ * 사용자 메시지 편집 — 해당 메시지 + 이후 모두 UI 에서 제거하고 입력창에 채움.
2394
+ * 서버 영속 history 는 그대로 유지 (다음 send 시 새 메시지로 추가).
2395
+ */
2396
+ private editUserMessage(idx: number, content: string) {
2397
+ // textarea 에는 marker 제거된 깨끗한 텍스트 — 사용자가 raw `{refid:N}` 보지 않게.
2398
+ // 다시 send 시 popup pickedMentions 기반으로 marker 재주입.
2399
+ const cleaned = stripMentionRefids(content)
2400
+ this.lines = this.lines.slice(0, idx)
2401
+ this.input = cleaned
2402
+ requestAnimationFrame(() => {
2403
+ const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
2404
+ ta?.focus()
2405
+ ta?.setSelectionRange(cleaned.length, cleaned.length)
2406
+ })
2407
+ }
2408
+
2409
+ /**
2410
+ * AI 응답 재생성 — 해당 assistant 메시지 + 이후 제거하고, 직전 user 메시지를 다시 send.
2411
+ */
2412
+ private async regenerateAssistant(idx: number) {
2413
+ if (this.busy) return
2414
+ // 직전 user 메시지 찾기
2415
+ let userIdx = idx - 1
2416
+ while (userIdx >= 0 && this.lines[userIdx].role !== 'user') userIdx--
2417
+ if (userIdx < 0) return
2418
+ // textarea 에는 marker 제거된 깨끗한 본문. send() 가 본문을 다시 line 으로 push 하는데,
2419
+ // pickedMentions 는 직전 send 에서 비워졌으므로 새 line 에는 marker 없음 — 클릭 시
2420
+ // runtime fallback resolver 가 처리. (영속된 원본 user 메시지의 marker 는 그대로 유지)
2421
+ const userContent = stripMentionRefids(this.lines[userIdx].content)
2422
+ // assistant 메시지 + 이후 제거
2423
+ this.lines = this.lines.slice(0, idx)
2424
+ this.input = userContent
2425
+ await this.send()
2426
+ }
2427
+
2428
+ // ── Patch revert ───────────────────────────────────────────────
2429
+
2430
+ private onRevertClick(patchId: string) {
2431
+ if (this.revertedPatchIds.has(patchId)) return
2432
+ this.revertedPatchIds = new Set([...this.revertedPatchIds, patchId])
2433
+ // 호스트 (board-modeller-page) 에게 revert 요청 — 호스트가 snapshot 복원 + 서버 mutation 호출
2434
+ this.dispatchEvent(
2435
+ new CustomEvent('board-edit-revert', {
2436
+ detail: { patchId },
2437
+ bubbles: true,
2438
+ composed: true
2439
+ })
2440
+ )
2441
+ }
2442
+
2443
+ private async send() {
2444
+ const text = this.input.trim()
2445
+ if (!text || this.busy) return
2446
+
2447
+ // pickedMentions / pickedUserMentions 에서 현재 텍스트에 실제 #/@ token 으로
2448
+ // 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
2449
+ const mentions: Array<{ token: string; refid: number }> = []
2450
+ for (const [token, refid] of this.pickedMentions.entries()) {
2451
+ if (text.includes(`#${token}`)) mentions.push({ token, refid })
2452
+ }
2453
+ const userMentions: Array<{ token: string; userId: string }> = []
2454
+ for (const [token, userId] of this.pickedUserMentions.entries()) {
2455
+ if (text.includes(`@${token}`)) userMentions.push({ token, userId })
2456
+ }
2457
+ let enrichedContent = injectMentionRefids(text, mentions)
2458
+ enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
2459
+
2460
+ // optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
2461
+ const userLocalId = `local-${++this._localSeq}-u`
2462
+ const asstLocalId = `local-${++this._localSeq}-a`
2463
+
2464
+ this.lines = [
2465
+ ...this.lines,
2466
+ { role: 'user', content: enrichedContent, _localId: userLocalId }
2467
+ ]
2468
+ this.input = ''
2469
+ this.busy = true
2470
+ this.errorMessage = undefined
2471
+
2472
+ // 가짜 pending 응답 표시 (UX 부드럽게)
2473
+ this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
2474
+
2475
+ try {
2476
+ // LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
2477
+ // pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
2478
+ const history = this.lines
2479
+ .filter(l => !l.pending && l._localId !== asstLocalId && l.role !== 'system')
2480
+ .map(l => ({ role: l.role, content: l.content }))
2481
+
2482
+ // 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
2483
+ // boardProvider 가 있으면 send 시점에 그것을 pull, 없으면 정적 currentBoard.
2484
+ const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
2485
+
2486
+ // mentions 는 위에서 이미 계산해 user line 에 thread — 그대로 mutation 입력으로 재사용.
2487
+ const result = await client.mutate({
2488
+ mutation: BOARD_AI_CHAT_MUTATION,
2489
+ variables: {
2490
+ input: buildChatMutationInput({
2491
+ sessionId: this.sessionId,
2492
+ history,
2493
+ liveBoard,
2494
+ scopes: this.scopes,
2495
+ knownTypes: this.knownTypes,
2496
+ categories: this.categories,
2497
+ componentSchemas: this.componentSchemas,
2498
+ selectedRefids: this.selectedRefids ?? [],
2499
+ mentions
2500
+ })
2501
+ }
2502
+ })
2503
+
2504
+ const out = result.data?.boardAIChat
2505
+ if (!out) throw new Error(i18next.t('board-ai.text.empty-response'))
2506
+
2507
+ // 전송 성공 — picks 정리 (다음 메시지부터는 새 picks 만)
2508
+ this.pickedMentions.clear()
2509
+ this.pickedUserMentions.clear()
2510
+
2511
+ // optimistic 라인을 서버 id 로 reconcile (방송 에코와 중복 방지, index 비의존).
2512
+ // 사용자 라인: enrich 된 본문 유지, id 만 부여. AI 라인: pending → 실제 응답.
2513
+ this._reconcileSentLine(userLocalId, out.userMessageId, {})
2514
+ this._reconcileSentLine(asstLocalId, out.assistantMessageId, {
2515
+ role: 'assistant',
2516
+ content: out.reply,
2517
+ patchSummary: out.patch?.summary,
2518
+ followUp: out.followUp || undefined,
2519
+ patchId: out.patchId || undefined,
2520
+ toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
2521
+ pending: false
2522
+ })
2523
+
2524
+ // 호스트로 patch 이벤트 전파
2525
+ if (out.patch) {
2526
+ this.dispatchEvent(
2527
+ new CustomEvent('board-edit-patch', {
2528
+ detail: {
2529
+ patch: out.patch,
2530
+ summary: out.patch.summary,
2531
+ confidence: out.patch.confidence,
2532
+ patchId: out.patchId,
2533
+ sessionId: out.sessionId
2534
+ },
2535
+ bubbles: true,
2536
+ composed: true
2537
+ })
2538
+ )
2539
+ }
2540
+ // 호스트로 ephemeral scene 조작 actions 전파 (selection / view / mode)
2541
+ if (Array.isArray(out.actions) && out.actions.length > 0) {
2542
+ this.dispatchEvent(
2543
+ new CustomEvent('board-action-execute', {
2544
+ detail: { actions: out.actions, sessionId: out.sessionId },
2545
+ bubbles: true,
2546
+ composed: true
2547
+ })
2548
+ )
2549
+ }
2550
+ if (out.followUp) {
2551
+ this.dispatchEvent(
2552
+ new CustomEvent('chat-followup', {
2553
+ detail: { question: out.followUp },
2554
+ bubbles: true,
2555
+ composed: true
2556
+ })
2557
+ )
2558
+ }
2559
+ } catch (e: any) {
2560
+ // pending(AI) placeholder 만 제거 — 사용자 라인은 보존(재시도). index 비의존.
2561
+ this.lines = this.lines.filter(l => l._localId !== asstLocalId)
2562
+ this.errorMessage = e?.message ?? String(e)
2563
+ this.lastFailedInput = text
2564
+ } finally {
2565
+ this.busy = false
2566
+ }
2567
+ }
2568
+ }
2569
+
2570
+ declare global {
2571
+ interface HTMLElementTagNameMap {
2572
+ 'ox-board-ai-chat': OxBoardAIChat
2573
+ }
2574
+ }