@things-factory/ai-assistant 10.1.47 → 10.1.52

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 (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -0
  9. package/client/setup-assistant-dock.ts +118 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +119 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +124 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
@@ -0,0 +1,607 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing, type PropertyValues, type TemplateResult } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next } from '@operato/i18n'
7
+ import { OxPrompt } from '@operato/popup'
8
+
9
+ import {
10
+ assistantSessionAdmin,
11
+ sessionActions,
12
+ type AssistantSessionAdmin,
13
+ type AssistantSessionEntry,
14
+ type AssistantSessionFilters
15
+ } from '../utils/assistant-session-admin.js'
16
+
17
+ const PAGE = 30
18
+
19
+
20
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
21
+ i18next.t(`ai-assistant.session.${key}`, { defaultValue, ...options })
22
+
23
+ /**
24
+ * The conversations on one anchor, managed - `<ox-assistant-session-manager>`.
25
+ *
26
+ * Search, a filter (all / mine / including hidden), paging, and on each row: open, rename, hide or
27
+ * unhide, private ⇄ public, delete. Taken from operato-twin's dock, where it was written first, so every
28
+ * dock gets it by placing this element (typically behind the toolbar's "All History").
29
+ *
30
+ * ── What is the host's ──────────────────────────────────────────────────────────
31
+ * Opening and starting a conversation belong to the dock's session controller, so this element asks
32
+ * with the toolbar's own events and does not open anything itself:
33
+ * assistant-session-select { sessionId } open that conversation
34
+ * assistant-session-reset start a new one
35
+ * assistant-session-changed { sessionId, change: 'renamed' | 'hidden' | 'unhidden' | 'removed' | 'visibility' }
36
+ * a row changed - the host refreshes its tabs; if the open
37
+ * conversation was removed or hidden, it moves off it
38
+ *
39
+ * ── What is the server's ────────────────────────────────────────────────────────
40
+ * Who may do what (`sessionActions`): rename any participant, hide anyone for their own list, delete
41
+ * the creator, visibility the owner. The buttons follow those hints; the doors decide again.
42
+ *
43
+ * ── Asking before what cannot be undone ─────────────────────────────────────────
44
+ * Delete, and opening a private conversation to the domain, ask on the row itself - [Cancel][OK].
45
+ * Making it private, hiding and renaming do not: each can be undone from the same row.
46
+ */
47
+ @customElement('ox-assistant-session-manager')
48
+ export class AssistantSessionManager extends LitElement {
49
+ static styles = css`
50
+ :host {
51
+ display: flex;
52
+ flex-direction: column;
53
+ min-height: 0;
54
+ font-size: 12.5px;
55
+ color: var(--md-sys-color-on-surface);
56
+ }
57
+
58
+ .bar {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 6px;
62
+ padding: 8px 10px;
63
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
64
+ }
65
+
66
+ .bar input,
67
+ .bar select {
68
+ min-width: 0;
69
+ height: 28px;
70
+ box-sizing: border-box;
71
+ border: 1px solid var(--md-sys-color-outline-variant);
72
+ border-radius: 6px;
73
+ background: var(--md-sys-color-surface-container-lowest);
74
+ color: inherit;
75
+ font: inherit;
76
+ padding: 0 8px;
77
+ }
78
+
79
+ .bar input {
80
+ flex: 1;
81
+ }
82
+
83
+ .new {
84
+ flex: none;
85
+ border: 0;
86
+ border-radius: 6px;
87
+ padding: 4px 8px;
88
+ background: transparent;
89
+ color: var(--md-sys-color-primary);
90
+ font: inherit;
91
+ font-weight: 650;
92
+ cursor: pointer;
93
+ }
94
+
95
+ .filters {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ align-items: center;
99
+ gap: 6px 10px;
100
+ padding: 6px 10px;
101
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
102
+ font-size: 11.5px;
103
+ color: var(--md-sys-color-on-surface-variant);
104
+ }
105
+
106
+ .filters select {
107
+ height: 26px;
108
+ border: 1px solid var(--md-sys-color-outline-variant);
109
+ border-radius: 6px;
110
+ background: var(--md-sys-color-surface-container-lowest);
111
+ color: inherit;
112
+ font: inherit;
113
+ }
114
+
115
+ .filters label {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ gap: 3px;
119
+ cursor: pointer;
120
+ }
121
+
122
+ .kind {
123
+ color: var(--md-sys-color-primary);
124
+ }
125
+
126
+ .list {
127
+ flex: 1;
128
+ min-height: 0;
129
+ overflow: auto;
130
+ }
131
+
132
+ .row {
133
+ position: relative;
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ padding: 9px 10px;
138
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
139
+ cursor: pointer;
140
+ }
141
+
142
+ .row:hover,
143
+ .row.current {
144
+ background: var(--md-sys-color-surface-container);
145
+ }
146
+
147
+ .row.hidden {
148
+ opacity: 0.6;
149
+ }
150
+
151
+ .dot {
152
+ flex: none;
153
+ width: 7px;
154
+ height: 7px;
155
+ border-radius: 50%;
156
+ }
157
+
158
+ .row.unread .dot {
159
+ background: var(--md-sys-color-primary);
160
+ }
161
+
162
+ .row md-icon {
163
+ --md-icon-size: 16px;
164
+ flex: none;
165
+ color: var(--md-sys-color-on-surface-variant);
166
+ }
167
+
168
+ .body {
169
+ flex: 1;
170
+ min-width: 0;
171
+ }
172
+
173
+ .title,
174
+ .prev {
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ .row.unread .title {
181
+ font-weight: 700;
182
+ }
183
+
184
+ .prev {
185
+ color: var(--md-sys-color-on-surface-variant);
186
+ font-size: 11px;
187
+ }
188
+
189
+ /* Row actions show on hover and on keyboard focus - the list's body is the names. */
190
+ .acts {
191
+ display: none;
192
+ gap: 2px;
193
+ }
194
+
195
+ .row:hover .acts,
196
+ .row:focus-within .acts {
197
+ display: flex;
198
+ }
199
+
200
+ button.icon {
201
+ display: inline-flex;
202
+ padding: 3px;
203
+ border: 0;
204
+ border-radius: 6px;
205
+ background: none;
206
+ color: var(--md-sys-color-on-surface-variant);
207
+ cursor: pointer;
208
+ }
209
+
210
+ button.icon:hover {
211
+ background: var(--md-sys-color-surface-container-high);
212
+ color: var(--md-sys-color-on-surface);
213
+ }
214
+
215
+ button.icon.danger:hover {
216
+ color: var(--md-sys-color-error);
217
+ }
218
+
219
+ .rename {
220
+ flex: 1;
221
+ min-width: 0;
222
+ height: 26px;
223
+ border: 1px solid var(--md-sys-color-primary);
224
+ border-radius: 6px;
225
+ padding: 0 6px;
226
+ font: inherit;
227
+ background: var(--md-sys-color-surface-container-lowest);
228
+ color: inherit;
229
+ }
230
+
231
+ .ask {
232
+ display: grid;
233
+ gap: 6px;
234
+ padding: 10px;
235
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
236
+ background: var(--md-sys-color-surface-container);
237
+ }
238
+
239
+ .ask .buttons {
240
+ display: flex;
241
+ justify-content: flex-end;
242
+ gap: 6px;
243
+ }
244
+
245
+ .ask button {
246
+ border: 1px solid var(--md-sys-color-outline-variant);
247
+ border-radius: 6px;
248
+ padding: 4px 10px;
249
+ background: var(--md-sys-color-surface-container-lowest);
250
+ color: inherit;
251
+ font: inherit;
252
+ cursor: pointer;
253
+ }
254
+
255
+ .ask button.ok {
256
+ border-color: var(--md-sys-color-primary);
257
+ background: var(--md-sys-color-primary);
258
+ color: var(--md-sys-color-on-primary);
259
+ }
260
+
261
+ .ask button.ok.danger {
262
+ border-color: var(--md-sys-color-error);
263
+ background: var(--md-sys-color-error);
264
+ color: var(--md-sys-color-on-error);
265
+ }
266
+
267
+ .note,
268
+ .empty {
269
+ padding: 14px 12px;
270
+ color: var(--md-sys-color-on-surface-variant);
271
+ }
272
+
273
+ [role='alert'] {
274
+ color: var(--md-sys-color-error);
275
+ }
276
+
277
+ .more,
278
+ .hiddenfilter {
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: center;
282
+ gap: 6px;
283
+ width: 100%;
284
+ padding: 8px;
285
+ border: 0;
286
+ border-top: 1px solid var(--md-sys-color-outline-variant);
287
+ background: none;
288
+ color: var(--md-sys-color-on-surface-variant);
289
+ font: inherit;
290
+ cursor: pointer;
291
+ }
292
+
293
+ .hiddenfilter md-icon {
294
+ --md-icon-size: 16px;
295
+ }
296
+ `
297
+
298
+ /** The anchor whose conversations are listed - the same one the dock's session controller opened. */
299
+ @property({ type: String }) anchorType = ''
300
+ @property({ type: String }) anchorId = ''
301
+ /** The conversation the dock has open - marked in the list. */
302
+ @property({ type: String }) sessionId = ''
303
+ /** The doors. Replaceable for tests; a host normally leaves it. */
304
+ @property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
305
+ /** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
306
+ @property({ type: String }) kind = ''
307
+ /** kind → the words people know it by (the modes' labels), for each row. */
308
+ @property({ attribute: false }) kindLabels: Record<string, string> = {}
309
+
310
+ @state() private entries: AssistantSessionEntry[] = []
311
+ @state() private total = 0
312
+ @state() private hiddenCount = 0
313
+ @state() private search = ''
314
+ @state() private allKinds = false
315
+ @state() private whose: NonNullable<AssistantSessionFilters['whose']> = 'all'
316
+ @state() private visibility: NonNullable<AssistantSessionFilters['visibility']> = 'all'
317
+ @state() private includeHidden = false
318
+ @state() private unreadOnly = false
319
+ @state() private pageSize = PAGE
320
+ @state() private loading = false
321
+ @state() private error = ''
322
+ @state() private renamingId = ''
323
+
324
+ private generation = 0
325
+ private searchTimer?: ReturnType<typeof setTimeout>
326
+
327
+ /** Read the list again - the host calls this after it opened or started a conversation. */
328
+ async reload(): Promise<void> {
329
+ const generation = ++this.generation
330
+ if (!this.anchorType || !this.anchorId) {
331
+ this.entries = []
332
+ this.total = this.hiddenCount = 0
333
+ return
334
+ }
335
+
336
+ this.loading = true
337
+ this.error = ''
338
+ try {
339
+ /*
340
+ * Paging reads again from the start rather than appending - live activity reorders the list, and
341
+ * appending a later page to an earlier order duplicates and drops rows.
342
+ */
343
+ const page = await this.admin.inbox({
344
+ anchorType: this.anchorType,
345
+ anchorId: this.anchorId,
346
+ search: this.search,
347
+ filters: {
348
+ kind: this.allKinds ? '' : this.kind,
349
+ whose: this.whose,
350
+ visibility: this.visibility,
351
+ includeHidden: this.includeHidden,
352
+ unreadOnly: this.unreadOnly
353
+ },
354
+ limit: this.pageSize,
355
+ offset: 0
356
+ })
357
+ if (generation !== this.generation) return
358
+ this.entries = page.entries
359
+ this.total = page.total
360
+ this.hiddenCount = page.hiddenCount
361
+ } catch (error) {
362
+ if (generation !== this.generation) return
363
+ this.entries = []
364
+ this.total = this.hiddenCount = 0
365
+ this.error = (error as Error).message || t('failed', 'could not load the conversation list.')
366
+ } finally {
367
+ if (generation === this.generation) this.loading = false
368
+ }
369
+ }
370
+
371
+ protected updated(changed: PropertyValues) {
372
+ if (changed.has('anchorType') || changed.has('anchorId') || changed.has('admin') || changed.has('kind')) void this.reload()
373
+
374
+ /*
375
+ * The rename box takes the keys the moment it opens, with the name selected. `autofocus` works once
376
+ * per document, so the second rename opened a box that ignored the typing (2026-09-24, operato-ecs).
377
+ */
378
+ if (changed.has('renamingId') && this.renamingId) {
379
+ const input = this.renderRoot.querySelector('input.rename') as HTMLInputElement | null
380
+ input?.focus()
381
+ input?.select()
382
+ }
383
+ }
384
+
385
+ disconnectedCallback() {
386
+ this.generation++
387
+ clearTimeout(this.searchTimer)
388
+ super.disconnectedCallback()
389
+ }
390
+
391
+ private emit(name: string, detail?: unknown) {
392
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
393
+ }
394
+
395
+ /* ── what the person does ────────────────────────────────────────────────────────── */
396
+
397
+ private onSearch(value: string) {
398
+ this.search = value
399
+ clearTimeout(this.searchTimer)
400
+ /* Not on every keystroke. A new search starts from the first page. */
401
+ this.searchTimer = setTimeout(() => {
402
+ this.pageSize = PAGE
403
+ void this.reload()
404
+ }, 200)
405
+ }
406
+
407
+ /** A filter changed - the list starts from its first page again. */
408
+ private refilter(change: () => void) {
409
+ change()
410
+ this.pageSize = PAGE
411
+ void this.reload()
412
+ }
413
+
414
+ private loadMore() {
415
+ this.pageSize += PAGE
416
+ void this.reload()
417
+ }
418
+
419
+ /** Runs one door, then reads the list again - on success and on failure alike, so the row shows the truth. */
420
+ private async act(entry: AssistantSessionEntry, change: string, run: () => Promise<boolean>, refused: string) {
421
+ this.renamingId = ''
422
+ try {
423
+ if (!(await run())) this.error = refused
424
+ else this.emit('assistant-session-changed', { sessionId: entry.id, change })
425
+ } catch (error) {
426
+ /* Never pass a failure silently - a person must not believe a conversation was deleted when it was not. */
427
+ this.error = (error as Error).message
428
+ }
429
+ const error = this.error
430
+ await this.reload()
431
+ if (error && !this.error) this.error = error
432
+ }
433
+
434
+ private rename(entry: AssistantSessionEntry, name: string) {
435
+ const trimmed = name.trim()
436
+ this.renamingId = ''
437
+ if (!trimmed || trimmed === entry.name) return
438
+ void this.act(entry, 'renamed', () => this.admin.rename(entry.id, trimmed), t('rename-refused', 'this conversation could not be renamed.'))
439
+ }
440
+
441
+ private setHidden(entry: AssistantSessionEntry, hidden: boolean) {
442
+ void this.act(entry, hidden ? 'hidden' : 'unhidden', () => this.admin.hide(entry.id, hidden), t('hide-refused', 'this conversation could not be hidden.'))
443
+ }
444
+
445
+ private setVisibility(entry: AssistantSessionEntry, visibility: 'open' | 'private') {
446
+ void this.act(
447
+ entry,
448
+ 'visibility',
449
+ () => this.admin.setVisibility(entry.id, visibility),
450
+ t('visibility-refused', 'only the owner of this conversation can change who reads it.')
451
+ )
452
+ }
453
+
454
+ private _removeSession(entry: AssistantSessionEntry) {
455
+ void this.act(entry, 'removed', () => this.admin.remove(entry.id), t('remove-refused', 'this conversation could not be deleted.'))
456
+ }
457
+
458
+ /* ── drawing ─────────────────────────────────────────────────────────────────────── */
459
+
460
+ render(): TemplateResult {
461
+ const shown = this.entries.length
462
+
463
+ return html`
464
+ <div class="bar">
465
+ <input
466
+ .value=${this.search}
467
+ placeholder=${t('search', 'Search names and last messages')}
468
+ aria-label=${t('search', 'Search names and last messages')}
469
+ @input=${(e: Event) => this.onSearch((e.target as HTMLInputElement).value)}
470
+ @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && this.onSearch('')}
471
+ />
472
+ <button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
473
+ </div>
474
+ <div class="filters">
475
+ ${this.kind
476
+ ? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
477
+ @change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
478
+ <option value="this">${this.kindLabels[this.kind] || t('filter-this-kind', 'This conversation')}</option>
479
+ <option value="all">${t('filter-all-kinds', 'All kinds here')}</option>
480
+ </select>`
481
+ : nothing}
482
+ <select aria-label=${t('filter-whose', 'Whose')} .value=${this.whose}
483
+ @change=${(e: Event) => this.refilter(() => (this.whose = (e.target as HTMLSelectElement).value as any))}>
484
+ <option value="all">${t('filter-all', 'All')}</option>
485
+ <option value="mine">${t('filter-mine', 'Started by me')}</option>
486
+ <option value="participant">${t('filter-participant', "I'm in")}</option>
487
+ </select>
488
+ <select aria-label=${t('filter-visibility', 'Visibility')} .value=${this.visibility}
489
+ @change=${(e: Event) => this.refilter(() => (this.visibility = (e.target as HTMLSelectElement).value as any))}>
490
+ <option value="all">${t('filter-any-visibility', 'Public and private')}</option>
491
+ <option value="open">${t('filter-open', 'Public')}</option>
492
+ <option value="private">${t('filter-private', 'Private')}</option>
493
+ </select>
494
+ <label><input type="checkbox" .checked=${this.unreadOnly}
495
+ @change=${(e: Event) => this.refilter(() => (this.unreadOnly = (e.target as HTMLInputElement).checked))} />${t('filter-unread', 'Unread')}</label>
496
+ <label><input type="checkbox" .checked=${this.includeHidden}
497
+ @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
498
+ </div>
499
+ <div class="list">
500
+ ${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
501
+ ${shown === 0 && !this.loading
502
+ ? html`<div class="empty">${this.search ? t('no-match', 'No conversation matches "{q}".', { q: this.search }) : t('none', 'No conversations yet.')}</div>`
503
+ : this.entries.map(entry => this.renderRow(entry))}
504
+ ${shown < this.total
505
+ ? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
506
+ : nothing}
507
+ </div>
508
+ <!-- A hidden conversation must not be forgotten: say how many, and one press shows them. -->
509
+ ${this.hiddenCount > 0 && !this.includeHidden
510
+ ? html`<button class="hiddenfilter" @click=${() => this.refilter(() => (this.includeHidden = true))}>
511
+ <md-icon>visibility_off</md-icon>${t('hidden-count', '{count} hidden', { count: this.hiddenCount })}
512
+ </button>`
513
+ : nothing}
514
+ `
515
+ }
516
+
517
+ private renderRow(entry: AssistantSessionEntry): TemplateResult {
518
+ const name = entry.name || t('untitled', 'Untitled conversation')
519
+ const actions = sessionActions(entry)
520
+ const isPrivate = entry.visibility === 'private'
521
+
522
+ if (this.renamingId === entry.id) {
523
+ return html`<div class="row">
524
+ <span class="dot"></span>
525
+ <input
526
+ class="rename"
527
+ .value=${entry.name || ''}
528
+ aria-label=${t('rename', 'Rename')}
529
+ @keydown=${(e: KeyboardEvent) => {
530
+ if (e.key === 'Enter') this.rename(entry, (e.target as HTMLInputElement).value)
531
+ if (e.key === 'Escape') this.renamingId = ''
532
+ }}
533
+ @blur=${(e: Event) => this.rename(entry, (e.target as HTMLInputElement).value)}
534
+ />
535
+ </div>`
536
+ }
537
+
538
+ return html`<div
539
+ class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''}"
540
+ @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}
541
+ >
542
+ <span class="dot"></span>
543
+ <md-icon title=${isPrivate ? t('private', 'Private - only its participants read it') : t('open', 'Public - everyone in this domain reads it')}
544
+ >${isPrivate ? 'lock' : 'public'}</md-icon
545
+ >
546
+ <span class="body">
547
+ <div class="title" @dblclick=${(e: Event) => { e.stopPropagation(); if (actions.rename) this.renamingId = entry.id }}>${name}</div>
548
+ <div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
549
+ </span>
550
+ <span class="acts" @click=${(e: Event) => e.stopPropagation()}>
551
+ ${actions.rename
552
+ ? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
553
+ <md-icon>edit</md-icon>
554
+ </button>`
555
+ : nothing}
556
+ ${actions.setVisibility
557
+ ? isPrivate
558
+ ? html`<button class="icon" title=${t('make-open', 'Make public')} aria-label=${t('make-open', 'Make public')}
559
+ @click=${() => this.askOpen(entry, name)}><md-icon>public</md-icon></button>`
560
+ : html`<button class="icon" title=${t('make-private', 'Make private')} aria-label=${t('make-private', 'Make private')}
561
+ @click=${() => this.setVisibility(entry, 'private')}><md-icon>lock</md-icon></button>`
562
+ : nothing}
563
+ <button
564
+ class="icon"
565
+ title=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
566
+ aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
567
+ @click=${() => this.setHidden(entry, !entry.hidden)}
568
+ >
569
+ <md-icon>${actions.unhide ? 'visibility' : 'visibility_off'}</md-icon>
570
+ </button>
571
+ ${actions.remove
572
+ ? html`<button class="icon danger" title=${t('remove', 'Delete')} aria-label=${t('remove', 'Delete')}
573
+ @click=${() => this.askRemove(entry, name)}><md-icon>delete</md-icon></button>`
574
+ : nothing}
575
+ </span>
576
+ </div>`
577
+ }
578
+
579
+ /** Before what cannot be taken back - the platform's confirm, [Cancel][OK] (decided 2026-09-25, §7-3). */
580
+ private async ask(title: string, text: string, confirmText: string): Promise<boolean> {
581
+ return !!(await OxPrompt.open({
582
+ type: 'warning',
583
+ title,
584
+ text,
585
+ confirmButton: { text: confirmText },
586
+ cancelButton: { text: t('cancel', 'Cancel') }
587
+ }))
588
+ }
589
+
590
+ private async askRemove(entry: AssistantSessionEntry, name: string) {
591
+ const ok = await this.ask(
592
+ t('remove', 'Delete'),
593
+ t('confirm-remove', 'Delete "{name}"? It disappears for every participant and cannot be brought back.', { name }),
594
+ t('remove', 'Delete')
595
+ )
596
+ if (ok) this._removeSession(entry)
597
+ }
598
+
599
+ private async askOpen(entry: AssistantSessionEntry, name: string) {
600
+ const ok = await this.ask(
601
+ t('make-open', 'Make public'),
602
+ t('confirm-open', 'Make "{name}" public? Everyone in this domain will be able to read what is already written.', { name }),
603
+ t('make-open', 'Make public')
604
+ )
605
+ if (ok) this.setVisibility(entry, 'open')
606
+ }
607
+ }
@@ -44,6 +44,10 @@ export function remoteMessageToLine(m: RemoteMessage): DedupLine {
44
44
  patchId: m.relatedPatchId ?? undefined,
45
45
  toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,
46
46
  groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,
47
+ /* Files sent with the turn (shared dock §9) - opened through the conversation's own route when drawn. */
48
+ attachments: Array.isArray((m as any).attachmentsJson)
49
+ ? (m as any).attachmentsJson.map((a: any) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))
50
+ : undefined,
47
51
  senderEmail: m.creator?.email ?? undefined,
48
52
  senderName: m.creator?.name ?? undefined,
49
53
  /*
@@ -94,6 +94,8 @@ export interface AssistantChatInputArgs {
94
94
  mode: { kind: string; target?: string }
95
95
  truncateAfterMessageId?: string
96
96
  hostContext?: any
97
+ /** Files stored with the conversation by attachToConversation, sent with this turn. */
98
+ attachmentIds?: string[]
97
99
  }
98
100
 
99
101
  /**
@@ -116,6 +118,7 @@ export function buildAssistantChatInput(args: AssistantChatInputArgs): Record<st
116
118
  messages: args.history,
117
119
  mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) },
118
120
  ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),
119
- ...(args.hostContext ? { hostContext: args.hostContext } : {})
121
+ ...(args.hostContext ? { hostContext: args.hostContext } : {}),
122
+ ...(args.attachmentIds?.length ? { attachmentIds: args.attachmentIds } : {})
120
123
  }
121
124
  }
@@ -41,14 +41,19 @@ describe('the panel does not raise a second tab axis', () => {
41
41
  * 이미지는 `hostContext.image` 로 사람 턴에 붙는 길이 있다.
42
42
  */
43
43
  describe('the attach control offers only what has a path', () => {
44
- it('★ 이미지만 받는다 — 읽히지 않는 것을 화면이 권하지 않는다', () => {
45
- /*
46
- * 여기서는 주석을 안 벗긴다. `image/*` 자체가 `/*` 를 품고 있어서, 주석 벗기개가 그 자리를 주석의
47
- * 시작으로 읽고 다음 `*` + `/` 까지 지워 버린다 — 값이 사라지고 시험은 「없다」고 답한다.
48
- * 이 값은 주석에 적힐 일이 없으므로 원문에서 그대로 읽는다.
49
- */
50
- const accept = readFileSync(join(__dirname, 'assistant-chat.ts'), 'utf-8').match(/accept="([^"]*)"/)
44
+ /*
45
+ * 2026-09-25 — 받을 것을 **모드가 선언한다**(design/ai/shared-dock.md §9-1). 이미지 · 글 파일 모두 이제 모델에
46
+ * 닿는 길이 있다(보낸 턴에는 원문, 그 뒤에는 readChatAttachment). 그래서 규칙이 「이미지만」에서 「모드가 선언한
47
+ * 것만, 선언이 없으면 아무것도」로 넓어졌다. 보드 문(boardAIChat)은 모드 정책을 모르므로 이미지 길 그대로다.
48
+ */
49
+ const source = readFileSync(join(__dirname, 'assistant-chat.ts'), 'utf-8')
51
50
 
52
- expect(accept?.[1]).toBe('image/*')
51
+ it('★ 중립 문은 모드의 선언만 받는다 — 선언이 없으면 첨부 단추가 없다', () => {
52
+ expect(source).toMatch(/accept=\$\{this\.usesAttachmentPolicy \? acceptOf\(this\.modeView\?\.attachments\) : 'image\/\*'\}/)
53
+ expect(source).toMatch(/return !this\.usesAttachmentPolicy \|\| !!this\.modeView\?\.attachments\?\.kinds\?\.length/)
54
+ })
55
+
56
+ it('보드 문은 이미지 길 그대로다', () => {
57
+ expect(source).toMatch(/: 'image\/\*'\}/)
53
58
  })
54
59
  })
package/client/index.ts CHANGED
@@ -14,6 +14,14 @@ export * from './components/mention-popup-helpers.js'
14
14
  export * from './utils/assistant-session-controller.js'
15
15
  export * from './utils/assistant-session-transport.js'
16
16
  export * from './components/assistant-session-toolbar.js'
17
+ export * from './utils/assistant-session-admin.js'
18
+ export * from './components/assistant-session-manager.js'
19
+ export * from './components/assistant-dock.js'
20
+ export * from './setup-assistant-dock.js'
21
+ export * from './utils/assistant-surface.js'
22
+ export * from './utils/assistant-proposals.js'
23
+ export * from './utils/assistant-ask.js'
24
+ export * from './utils/assistant-activity.js'
17
25
  export * from './utils/assistant-request-context.js'
18
26
  export * from './utils/assistant-capability.js'
19
27
  export * from './utils/assistant-capability-read.js'