dsh-conversation-recycle-bin 0.2.2 → 0.3.1

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.
package/client.js CHANGED
@@ -106,7 +106,7 @@ window.__ModuleLoader__.load({
106
106
  'dialog.partial': 'Deleted {done}, failed {failed}.',
107
107
  'dialog.noSession': 'The conversation no longer exists.',
108
108
  'dialog.blocked': 'This conversation cannot be deleted: {message}',
109
- 'dialog.reloadHint': 'The conversation list refreshes automatically.',
109
+ 'dialog.reloadHint': 'The conversation list refreshes when you close this dialog.',
110
110
  'trash.title': 'Recycle bin',
111
111
  'trash.empty': 'The recycle bin is empty.',
112
112
  'trash.deletedAt': 'Deleted {time}',
@@ -145,8 +145,45 @@ window.__ModuleLoader__.load({
145
145
  'state.limits': 'Limits',
146
146
  'state.available': 'available',
147
147
  'state.unavailable': 'unavailable',
148
+ 'state.notDetected': 'not detected',
148
149
  'state.rate': '{used}/{limit} deletions this minute',
149
150
  'state.batch': 'Effective batch limit: {limit}',
151
+ 'feedback.button': 'Feedback',
152
+ 'feedback.bannerTitle': 'Ran into a problem or have an idea?',
153
+ 'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
154
+ 'feedback.title': 'Send feedback',
155
+ 'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
156
+ 'feedback.categoryLabel': 'Type',
157
+ 'feedback.categoryBug': 'Problem / bug',
158
+ 'feedback.categorySuggestion': 'Suggestion',
159
+ 'feedback.categoryOther': 'Other',
160
+ 'feedback.titleLabel': 'Title',
161
+ 'feedback.titlePlaceholder': 'One line that summarises it',
162
+ 'feedback.bodyLabel': 'Message',
163
+ 'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
164
+ 'feedback.emailLabel': 'Your email (optional)',
165
+ 'feedback.emailPlaceholder': 'you@example.com',
166
+ 'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
167
+ 'feedback.emailHintAsFrom': 'Shown as the sender (From) of the mail.',
168
+ 'feedback.contextLabel': 'Attached automatically',
169
+ 'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
170
+ 'feedback.contextVersion': 'Plugin version',
171
+ 'feedback.contextPlatform': 'Platform',
172
+ 'feedback.contextLocale': 'Language',
173
+ 'feedback.send': 'Send',
174
+ 'feedback.sending': 'Sending…',
175
+ 'feedback.sent': 'Feedback sent. Thank you!',
176
+ 'feedback.tooShortTitle': 'The title needs at least {min} characters.',
177
+ 'feedback.tooLongTitle': 'The title may be at most {max} characters.',
178
+ 'feedback.tooShortBody': 'The message needs at least {min} characters.',
179
+ 'feedback.tooLongBody': 'The message may be at most {max} characters.',
180
+ 'feedback.invalidEmail': 'That does not look like an email address.',
181
+ 'feedback.tooLongEmail': 'The email address may be at most {max} characters.',
182
+ 'feedback.unavailable': 'The feedback channel is not configured on this Host.',
183
+ 'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
184
+ 'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
185
+ 'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
186
+ 'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
150
187
  'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
151
188
  'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
152
189
  'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
@@ -222,7 +259,7 @@ window.__ModuleLoader__.load({
222
259
  'dialog.partial': '成功 {done} 个,失败 {failed} 个。',
223
260
  'dialog.noSession': '该对话已不存在。',
224
261
  'dialog.blocked': '该对话无法删除:{message}',
225
- 'dialog.reloadHint': '会话列表将自动刷新。',
262
+ 'dialog.reloadHint': '关闭本对话框后会刷新会话列表。',
226
263
  'trash.title': '回收站',
227
264
  'trash.empty': '回收站为空。',
228
265
  'trash.deletedAt': '删除于 {time}',
@@ -261,8 +298,45 @@ window.__ModuleLoader__.load({
261
298
  'state.limits': '限制',
262
299
  'state.available': '可用',
263
300
  'state.unavailable': '不可用',
301
+ 'state.notDetected': '未检测到',
264
302
  'state.rate': '本分钟已删除 {used}/{limit}',
265
303
  'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
304
+ 'feedback.button': '反馈',
305
+ 'feedback.bannerTitle': '遇到问题,或有改进建议?',
306
+ 'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
307
+ 'feedback.title': '发送反馈',
308
+ 'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
309
+ 'feedback.categoryLabel': '类型',
310
+ 'feedback.categoryBug': '问题 / Bug',
311
+ 'feedback.categorySuggestion': '建议',
312
+ 'feedback.categoryOther': '其他',
313
+ 'feedback.titleLabel': '标题',
314
+ 'feedback.titlePlaceholder': '用一句话概括',
315
+ 'feedback.bodyLabel': '正文',
316
+ 'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
317
+ 'feedback.emailLabel': '你的邮箱(选填)',
318
+ 'feedback.emailPlaceholder': 'you@example.com',
319
+ 'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
320
+ 'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人。',
321
+ 'feedback.contextLabel': '自动附带',
322
+ 'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
323
+ 'feedback.contextVersion': '插件版本',
324
+ 'feedback.contextPlatform': '平台',
325
+ 'feedback.contextLocale': '语言',
326
+ 'feedback.send': '发送',
327
+ 'feedback.sending': '正在发送…',
328
+ 'feedback.sent': '反馈已发送,谢谢!',
329
+ 'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
330
+ 'feedback.tooLongTitle': '标题最多 {max} 个字符。',
331
+ 'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
332
+ 'feedback.tooLongBody': '正文最多 {max} 个字符。',
333
+ 'feedback.invalidEmail': '这看起来不是一个邮箱地址。',
334
+ 'feedback.tooLongEmail': '邮箱最多 {max} 个字符。',
335
+ 'feedback.unavailable': '当前宿主未配置反馈通道。',
336
+ 'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
337
+ 'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
338
+ 'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
339
+ 'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
266
340
  'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
267
341
  'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
268
342
  'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
@@ -484,11 +558,14 @@ window.__ModuleLoader__.load({
484
558
 
485
559
  const flashStore = {
486
560
  message: null,
561
+ timer: null,
487
562
  listeners: new Set(),
488
563
  show(message) {
564
+ if (this.timer !== null) clearTimeout(this.timer)
489
565
  this.message = message
490
566
  for (const listener of this.listeners) listener(this.message)
491
- setTimeout(() => {
567
+ this.timer = setTimeout(() => {
568
+ this.timer = null
492
569
  if (this.message === message) {
493
570
  this.message = null
494
571
  for (const listener of this.listeners) listener(null)
@@ -590,7 +667,35 @@ window.__ModuleLoader__.load({
590
667
 
591
668
  function reloadApp(delay = 900) {
592
669
  if (typeof window === 'undefined' || typeof window.location?.reload !== 'function') return
593
- window.setTimeout(() => window.location.reload(), delay)
670
+ reloadTimer = window.setTimeout(() => {
671
+ reloadTimer = null
672
+ reloadPending = false
673
+ window.location.reload()
674
+ }, delay)
675
+ }
676
+
677
+ // A fallback reload is armed when the Host cannot close the deleted
678
+ // conversation in place. The result dialog is the only place a partial
679
+ // batch reports its per-session failures, so the reload waits for the user
680
+ // to dismiss it; the timer is only a safety net for a dialog left open.
681
+ const RELOAD_FALLBACK_DELAY = 5_000
682
+ let reloadPending = false
683
+ let reloadTimer = null
684
+ // Set when the user dismisses the result dialog. `refreshAfterMutation`
685
+ // settles after the dialog is already on screen, so the dismissal can
686
+ // arrive first; the reload then happens at once instead of arming a timer.
687
+ let reloadDialogDismissed = false
688
+
689
+ function takeReloadPending() {
690
+ const pending = reloadPending
691
+ reloadPending = false
692
+ // Cancel the safety-net timer, otherwise dismissing the dialog would
693
+ // reload once now and once again when the original timer fires.
694
+ if (pending && reloadTimer !== null) {
695
+ if (typeof window !== 'undefined') window.clearTimeout?.(reloadTimer)
696
+ reloadTimer = null
697
+ }
698
+ return pending
594
699
  }
595
700
 
596
701
  function service(name) {
@@ -627,7 +732,7 @@ window.__ModuleLoader__.load({
627
732
  async function refreshAfterMutation(options = {}) {
628
733
  const sessionIds = Array.isArray(options.sessionIds) ? options.sessionIds : []
629
734
  const closeCurrent = options.closeCurrent !== false
630
- const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : 900
735
+ const fallbackDelay = Number.isFinite(options.fallbackDelay) ? options.fallbackDelay : RELOAD_FALLBACK_DELAY
631
736
  const current = openSessionId()
632
737
  const sessions = service('sessions')
633
738
  let refreshed = false
@@ -665,7 +770,13 @@ window.__ModuleLoader__.load({
665
770
  }
666
771
  }
667
772
  }
668
- if (!refreshed) reloadApp(fallbackDelay)
773
+ if (!refreshed) {
774
+ if (reloadDialogDismissed) reloadApp(0)
775
+ else {
776
+ reloadPending = true
777
+ reloadApp(fallbackDelay)
778
+ }
779
+ }
669
780
  if (options.notify !== false) mutationStore.bump()
670
781
  return refreshed
671
782
  }
@@ -696,6 +807,16 @@ window.__ModuleLoader__.load({
696
807
  h('path', { d: 'M20 4v5h-5' }))
697
808
  }
698
809
 
810
+ function GlyphFeedback({ size = 14 }) {
811
+ return h('svg', {
812
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
813
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
814
+ 'aria-hidden': 'true',
815
+ },
816
+ h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
817
+ h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
818
+ }
819
+
699
820
  function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
700
821
  return h('button', {
701
822
  type,
@@ -746,6 +867,55 @@ window.__ModuleLoader__.load({
746
867
  }
747
868
  }
748
869
 
870
+ /* ------------------------------------------------------------------ *
871
+ * Feedback form (pure helpers, shared with the Host's validation)
872
+ * ------------------------------------------------------------------ */
873
+
874
+ // Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
875
+ // every field; this copy only exists to disable the submit button early and
876
+ // to point at the offending input.
877
+ const FEEDBACK_LIMITS = Object.freeze({
878
+ titleMin: 4,
879
+ titleMax: 120,
880
+ bodyMin: 10,
881
+ bodyMax: 4_000,
882
+ emailMax: 200,
883
+ })
884
+
885
+ /** Same loose shape the Host accepts: `a@b.cc`, no whitespace or separators. */
886
+ const EMAIL_PATTERN = /^[^\s@,;:<>()[\]\\]+@[^\s@,;:<>()[\]\\]+\.[^\s@,;:<>()[\]\\]{2,}$/
887
+
888
+ /**
889
+ * @param {{title?: string, body?: string, email?: string}} input
890
+ * @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
891
+ */
892
+ function validateFeedbackInput(input) {
893
+ const title = String(input?.title ?? '').trim()
894
+ const body = String(input?.body ?? '').trim()
895
+ const email = String(input?.email ?? '').trim()
896
+ if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
897
+ if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
898
+ if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
899
+ if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
900
+ // Same order as the Host (`lib/host/feedback.js:241-250`): shape first,
901
+ // then length. The reverse told a 300-character address it was "not an
902
+ // email" while the Host said "too long" for the identical submission.
903
+ if (email !== '' && !EMAIL_PATTERN.test(email)) return { ok: false, key: 'feedback.invalidEmail', vars: {} }
904
+ if (email.length > FEEDBACK_LIMITS.emailMax) return { ok: false, key: 'feedback.tooLongEmail', vars: { max: FEEDBACK_LIMITS.emailMax } }
905
+ return { ok: true }
906
+ }
907
+
908
+ /** Context the dialog shows before sending; nothing here is user content. */
909
+ function collectFeedbackContext() {
910
+ const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
911
+ ? navigator.userAgent
912
+ : ''
913
+ return {
914
+ platform: userAgent.slice(0, 160),
915
+ locale: currentLocaleId(),
916
+ }
917
+ }
918
+
749
919
  /* ------------------------------------------------------------------ *
750
920
  * Confirmation dialog
751
921
  * ------------------------------------------------------------------ */
@@ -766,6 +936,15 @@ window.__ModuleLoader__.load({
766
936
  const [error, setError] = useState(null)
767
937
  const [phrase, setPhrase] = useState('')
768
938
  const [done, setDone] = useState(null)
939
+
940
+ // Dismissing the result must also complete a reload the Host armed
941
+ // because it cannot close the deleted conversation in place; otherwise
942
+ // the safety-net timer would fire later and reload under the user's feet.
943
+ function closeDone() {
944
+ reloadDialogDismissed = true
945
+ dialogStore.close()
946
+ if (takeReloadPending()) reloadApp(0)
947
+ }
769
948
  const requestRef = useRef(0)
770
949
 
771
950
  const load = useCallback(async (nextMode, nextMemory, options = {}) => {
@@ -797,7 +976,9 @@ window.__ModuleLoader__.load({
797
976
 
798
977
  useEffect(() => {
799
978
  function onKey(event) {
800
- if (event.key === 'Escape' && busy !== true) dialogStore.close()
979
+ // Escape dismisses the result dialog too, so it must release the
980
+ // armed reload exactly like the Close button does.
981
+ if (event.key === 'Escape' && busy !== true) closeDone()
801
982
  }
802
983
  window.addEventListener('keydown', onKey)
803
984
  return () => window.removeEventListener('keydown', onKey)
@@ -823,6 +1004,9 @@ window.__ModuleLoader__.load({
823
1004
 
824
1005
  async function submit() {
825
1006
  if (confirmation === null || busy) return
1007
+ // A new submission starts a new result cycle: a dismissal from the
1008
+ // previous one must not force this run's reload to happen at once.
1009
+ reloadDialogDismissed = false
826
1010
  setBusy(true)
827
1011
  setError(null)
828
1012
  try {
@@ -873,7 +1057,10 @@ window.__ModuleLoader__.load({
873
1057
  }
874
1058
 
875
1059
  if (done !== null) {
876
- return h('div', { className: 'dc-backdrop' },
1060
+ return h('div', {
1061
+ className: 'dc-backdrop',
1062
+ onMouseDown: (event) => { if (event.target === event.currentTarget) closeDone() },
1063
+ },
877
1064
  h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true' },
878
1065
  h('h2', { className: 'dc-dialog-title' }, t('dialog.done', { count: done.count, memory: done.memory })),
879
1066
  done.failed > 0 ? h('p', { className: 'dc-error-text' }, t('dialog.partial', { done: done.count, failed: done.failed })) : null,
@@ -884,7 +1071,7 @@ window.__ModuleLoader__.load({
884
1071
  : null,
885
1072
  h('p', { className: 'dc-hint' }, t('dialog.reloadHint')),
886
1073
  h('div', { className: 'dc-actions' },
887
- h(Button, { variant: 'primary', onClick: () => { dialogStore.close() } }, t('action.close')))))
1074
+ h(Button, { variant: 'primary', onClick: () => closeDone() }, t('action.close')))))
888
1075
  }
889
1076
 
890
1077
  return h('div', {
@@ -991,7 +1178,172 @@ window.__ModuleLoader__.load({
991
1178
  if (first !== undefined) return adapterReason(first.reason)
992
1179
  const stores = Array.isArray(memory?.stores) ? memory.stores : []
993
1180
  const store = stores.find((item) => item.available === false && typeof item.reason === 'string')
994
- return store === undefined ? 'not detected' : adapterReason(store.reason)
1181
+ // `not detected` used to be hard-coded English, so a Chinese page showed
1182
+ // `记忆存储不可用:not detected`. Everything else here already routes
1183
+ // through the string table.
1184
+ return store === undefined ? t('state.notDetected') : adapterReason(store.reason)
1185
+ }
1186
+
1187
+ /* ------------------------------------------------------------------ *
1188
+ * Feedback dialog
1189
+ * ------------------------------------------------------------------ */
1190
+
1191
+ /**
1192
+ * Settings page "反馈" button.
1193
+ *
1194
+ * The Host performs the delivery, so the page never holds a mail
1195
+ * credential. The auto-attached context is rendered BEFORE the user
1196
+ * commits, so nothing leaves the machine that they did not see.
1197
+ */
1198
+ function FeedbackDialog({ payload }) {
1199
+ useLocaleTick()
1200
+ const feedback = payload?.feedback ?? null
1201
+ const version = typeof payload?.version === 'string' ? payload.version : ''
1202
+ const [category, setCategory] = useState('bug')
1203
+ const [title, setTitle] = useState('')
1204
+ const [body, setBody] = useState('')
1205
+ const [email, setEmail] = useState('')
1206
+ const [busy, setBusy] = useState(false)
1207
+ const [error, setError] = useState(null)
1208
+ const validation = validateFeedbackInput({ title, body, email })
1209
+ const context = collectFeedbackContext()
1210
+ // A Host without a mail transport advertises `enabled: false`; the form is
1211
+ // still rendered read-only so the explanation is visible instead of a
1212
+ // button that fails on click.
1213
+ const unavailable = feedback?.enabled !== true
1214
+ // Nothing has been typed on the first render, so the "too short" rule must
1215
+ // not greet the user. It starts applying once the user edits a field OR
1216
+ // leaves one: gating on `dirty` alone left the disabled submit button with
1217
+ // no visible reason for anyone who only tabbed through the form.
1218
+ const [dirty, setDirty] = useState(false)
1219
+ const [touched, setTouched] = useState(false)
1220
+ const validationVisible = (dirty === true || touched === true) && validation.ok !== true
1221
+
1222
+ useEffect(() => {
1223
+ function onKey(event) {
1224
+ if (event.key === 'Escape' && busy !== true) dialogStore.close()
1225
+ }
1226
+ window.addEventListener('keydown', onKey)
1227
+ return () => window.removeEventListener('keydown', onKey)
1228
+ }, [busy])
1229
+
1230
+ async function submit() {
1231
+ if (busy || unavailable || validation.ok !== true) return
1232
+ setBusy(true)
1233
+ setError(null)
1234
+ try {
1235
+ await api('/feedback', {
1236
+ method: 'POST',
1237
+ timeoutMs: 30_000,
1238
+ body: {
1239
+ category,
1240
+ title: title.trim(),
1241
+ body: body.trim(),
1242
+ email: email.trim(),
1243
+ context: {
1244
+ pluginVersion: version,
1245
+ platform: context.platform,
1246
+ locale: context.locale,
1247
+ },
1248
+ },
1249
+ })
1250
+ flashStore.show(t('feedback.sent'))
1251
+ dialogStore.close()
1252
+ } catch (caught) {
1253
+ // The Host log already records the stable code; this keeps the form
1254
+ // path debuggable from the browser console without a network tab.
1255
+ console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
1256
+ setError(caught)
1257
+ } finally {
1258
+ setBusy(false)
1259
+ }
1260
+ }
1261
+
1262
+ return h('div', {
1263
+ className: 'dc-backdrop',
1264
+ onMouseDown: (event) => {
1265
+ if (event.target === event.currentTarget && busy !== true) dialogStore.close()
1266
+ },
1267
+ },
1268
+ h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
1269
+ h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
1270
+ h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
1271
+ unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
1272
+ h(ErrorBanner, { error }),
1273
+
1274
+ h('label', { className: 'dc-field' },
1275
+ h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
1276
+ h('select', {
1277
+ className: 'dc-input',
1278
+ value: category,
1279
+ disabled: busy,
1280
+ onBlur: () => setTouched(true),
1281
+ onChange: (event) => { setDirty(true); setCategory(event.target.value) },
1282
+ },
1283
+ h('option', { value: 'bug' }, t('feedback.categoryBug')),
1284
+ h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
1285
+ h('option', { value: 'other' }, t('feedback.categoryOther')))),
1286
+
1287
+ h('label', { className: 'dc-field' },
1288
+ h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
1289
+ h('input', {
1290
+ type: 'text',
1291
+ className: 'dc-input',
1292
+ value: title,
1293
+ maxLength: FEEDBACK_LIMITS.titleMax,
1294
+ placeholder: t('feedback.titlePlaceholder'),
1295
+ disabled: busy,
1296
+ autoFocus: true,
1297
+ onBlur: () => setTouched(true),
1298
+ onChange: (event) => { setDirty(true); setTitle(event.target.value) },
1299
+ })),
1300
+
1301
+ h('label', { className: 'dc-field' },
1302
+ h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
1303
+ h('textarea', {
1304
+ className: 'dc-input dc-textarea',
1305
+ value: body,
1306
+ rows: 6,
1307
+ maxLength: FEEDBACK_LIMITS.bodyMax,
1308
+ placeholder: t('feedback.bodyPlaceholder'),
1309
+ disabled: busy,
1310
+ onBlur: () => setTouched(true),
1311
+ onChange: (event) => { setDirty(true); setBody(event.target.value) },
1312
+ })),
1313
+
1314
+ h('label', { className: 'dc-field' },
1315
+ h('span', { className: 'dc-field-label' }, t('feedback.emailLabel')),
1316
+ h('input', {
1317
+ type: 'email',
1318
+ className: 'dc-input',
1319
+ value: email,
1320
+ maxLength: FEEDBACK_LIMITS.emailMax,
1321
+ placeholder: t('feedback.emailPlaceholder'),
1322
+ autoComplete: 'email',
1323
+ disabled: busy,
1324
+ onBlur: () => setTouched(true),
1325
+ onChange: (event) => { setDirty(true); setEmail(event.target.value) },
1326
+ }),
1327
+ h('div', { className: 'dc-hint' }, t(feedback?.visitorAsFrom === true ? 'feedback.emailHintAsFrom' : 'feedback.emailHint'))),
1328
+
1329
+ h('div', { className: 'dc-context' },
1330
+ h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
1331
+ h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
1332
+ h('ul', { className: 'dc-context-list' },
1333
+ h('li', null, `${t('feedback.contextVersion')}: ${version === '' ? '—' : version}`),
1334
+ h('li', null, `${t('feedback.contextPlatform')}: ${context.platform === '' ? '—' : context.platform}`),
1335
+ h('li', null, `${t('feedback.contextLocale')}: ${context.locale}`))),
1336
+
1337
+ validationVisible ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1338
+ busy ? h(Spinner, { label: t('feedback.sending') }) : null,
1339
+
1340
+ h('div', { className: 'dc-actions' },
1341
+ h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1342
+ h(Button, {
1343
+ variant: 'primary',
1344
+ onClick: () => { void submit() },
1345
+ disabled: busy || unavailable || validation.ok !== true,
1346
+ }, busy ? t('feedback.sending') : t('feedback.send')))))
995
1347
  }
996
1348
 
997
1349
  /* ------------------------------------------------------------------ *
@@ -1014,8 +1366,15 @@ window.__ModuleLoader__.load({
1014
1366
  function DialogHost() {
1015
1367
  const payload = useDialogState()
1016
1368
  const flash = useFlashMessage()
1369
+ // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1370
+ // payload without a kind keeps its old meaning.
1371
+ const dialog = payload === null
1372
+ ? null
1373
+ : payload.kind === 'feedback'
1374
+ ? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
1375
+ : h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
1017
1376
  const content = h(React.Fragment, null,
1018
- payload === null ? null : h(DeleteDialog, { payload, key: payload.key ?? payload.sessionIds.join(',') }),
1377
+ dialog,
1019
1378
  flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
1020
1379
  return portalIntoBody(content)
1021
1380
  }
@@ -1082,7 +1441,7 @@ window.__ModuleLoader__.load({
1082
1441
  * log) gets a Restore button: `performDiscard` refuses exactly those
1083
1442
  * entries, so without this the only way out was a manual API call.
1084
1443
  */
1085
- function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId }) {
1444
+ function IncompleteEntries({ entries, discardTarget, discardPhrase, onPhrase, onStart, onCancel, onDiscard, onRestore, restoreBusyId, discardBusyId = null }) {
1086
1445
  return h('div', { className: 'dc-panel dc-incomplete' },
1087
1446
  h('div', { className: 'dc-state-title' }, `${t('trash.incompleteTitle')} (${entries.length})`),
1088
1447
  h('p', { className: 'dc-hint' }, t('trash.incompleteHint')),
@@ -1110,10 +1469,10 @@ window.__ModuleLoader__.load({
1110
1469
  }),
1111
1470
  h(Button, {
1112
1471
  variant: 'danger',
1113
- disabled: discardPhrase.trim() !== 'DELETE',
1472
+ disabled: discardBusyId === entry.trashId || discardPhrase.trim() !== 'DELETE',
1114
1473
  onClick: () => onDiscard(entry.trashId),
1115
1474
  }, t('trash.discard')),
1116
- h(Button, { onClick: onCancel }, t('action.cancel')))
1475
+ h(Button, { disabled: discardBusyId === entry.trashId, onClick: onCancel }, t('action.cancel')))
1117
1476
  : h(Button, {
1118
1477
  onClick: () => onStart(entry.trashId),
1119
1478
  title: t('trash.confirmDiscard'),
@@ -1164,8 +1523,17 @@ window.__ModuleLoader__.load({
1164
1523
  const [discardTarget, setDiscardTarget] = useState(null)
1165
1524
  const [discardPhrase, setDiscardPhrase] = useState('')
1166
1525
  const [restoreBusyId, setRestoreBusyId] = useState(null)
1526
+ const [purgeBusy, setPurgeBusy] = useState(false)
1527
+ const [discardBusyId, setDiscardBusyId] = useState(null)
1528
+
1529
+ // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1530
+ // purge triggers), and the older response must not overwrite the newer.
1531
+ const refreshSerial = useRef(0)
1167
1532
 
1168
1533
  const refresh = useCallback(async () => {
1534
+ const serial = refreshSerial.current + 1
1535
+ refreshSerial.current = serial
1536
+ const stale = () => serial !== refreshSerial.current
1169
1537
  setLoading(true)
1170
1538
  setError(null)
1171
1539
  try {
@@ -1175,6 +1543,7 @@ window.__ModuleLoader__.load({
1175
1543
  api('/trash'),
1176
1544
  api('/audit?limit=50'),
1177
1545
  ])
1546
+ if (stale()) return
1178
1547
  const nextSessions = Array.isArray(sessionsResponse.sessions) ? sessionsResponse.sessions : []
1179
1548
  setState(stateResponse.state)
1180
1549
  setSessions(nextSessions)
@@ -1188,9 +1557,9 @@ window.__ModuleLoader__.load({
1188
1557
  return next.size === current.size ? current : next
1189
1558
  })
1190
1559
  } catch (caught) {
1191
- setError(caught)
1560
+ if (!stale()) setError(caught)
1192
1561
  } finally {
1193
- setLoading(false)
1562
+ if (!stale()) setLoading(false)
1194
1563
  }
1195
1564
  }, [])
1196
1565
 
@@ -1247,6 +1616,8 @@ window.__ModuleLoader__.load({
1247
1616
  }
1248
1617
 
1249
1618
  async function purge(trashId, phrase) {
1619
+ if (purgeBusy === true) return
1620
+ setPurgeBusy(true)
1250
1621
  setError(null)
1251
1622
  const entry = trash.find((item) => item.trashId === trashId)
1252
1623
  try {
@@ -1263,10 +1634,14 @@ window.__ModuleLoader__.load({
1263
1634
  })
1264
1635
  } catch (caught) {
1265
1636
  setError(caught)
1637
+ } finally {
1638
+ setPurgeBusy(false)
1266
1639
  }
1267
1640
  }
1268
1641
 
1269
1642
  async function discard(trashId, phrase) {
1643
+ if (discardBusyId !== null) return
1644
+ setDiscardBusyId(trashId)
1270
1645
  setError(null)
1271
1646
  try {
1272
1647
  await api('/trash/discard', { method: 'POST', body: { trashId, confirmPhrase: phrase } })
@@ -1276,6 +1651,8 @@ window.__ModuleLoader__.load({
1276
1651
  await refresh()
1277
1652
  } catch (caught) {
1278
1653
  setError(caught)
1654
+ } finally {
1655
+ setDiscardBusyId(null)
1279
1656
  }
1280
1657
  }
1281
1658
 
@@ -1294,13 +1671,34 @@ window.__ModuleLoader__.load({
1294
1671
  : (state?.limits?.maxBatchSessions ?? 0)
1295
1672
  const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
1296
1673
 
1674
+ const openFeedback = () => dialogStore.open({
1675
+ kind: 'feedback',
1676
+ key: 'feedback',
1677
+ version: typeof state?.version === 'string' ? state.version : '',
1678
+ feedback: state?.feedback ?? null,
1679
+ })
1680
+
1297
1681
  return h('div', { className: 'dc-root' },
1682
+ // Top-of-section call to action, above the title: it is the first thing
1683
+ // a user unsure about the plugin should see. Rendered only when the Host
1684
+ // reports a configured transport, so a profile without mail settings
1685
+ // never shows a button that cannot work (the dialog would otherwise
1686
+ // explain a failure to send).
1687
+ state?.feedback?.enabled === true
1688
+ ? h('div', { className: 'dc-feedback-bar' },
1689
+ h('div', { className: 'dc-feedback-bar-text' },
1690
+ h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
1691
+ h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
1692
+ h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
1693
+ h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
1694
+ : null,
1298
1695
  h('div', { className: 'dc-header' },
1299
1696
  h('div', null,
1300
1697
  h('h1', { className: 'dc-title' }, t('section.title')),
1301
1698
  h('p', { className: 'dc-hint' }, t('section.subtitle'))),
1302
- h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
1303
- h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh'))),
1699
+ h('div', { className: 'dc-header-actions' },
1700
+ h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
1701
+ h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
1304
1702
 
1305
1703
  h(ErrorBanner, { error }),
1306
1704
 
@@ -1324,13 +1722,13 @@ window.__ModuleLoader__.load({
1324
1722
  h('div', { className: 'dc-hint' }, t('state.batch', { limit: effectiveBatch > 0 ? effectiveBatch : '—' })))) : null,
1325
1723
 
1326
1724
  h('div', { className: 'dc-tabs', role: 'tablist' },
1327
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1328
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1329
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1725
+ h('button', { id: 'dc-tab-trash', type: 'button', role: 'tab', 'aria-selected': tab === 'trash', 'aria-controls': 'dc-panel-trash', className: `dc-tab${tab === 'trash' ? ' dc-tab-active' : ''}`, onClick: () => setTab('trash') }, t('trash.title')),
1726
+ h('button', { id: 'dc-tab-batch', type: 'button', role: 'tab', 'aria-selected': tab === 'batch', 'aria-controls': 'dc-panel-batch', className: `dc-tab${tab === 'batch' ? ' dc-tab-active' : ''}`, onClick: () => setTab('batch') }, t('batch.title')),
1727
+ h('button', { id: 'dc-tab-audit', type: 'button', role: 'tab', 'aria-selected': tab === 'audit', 'aria-controls': 'dc-panel-audit', className: `dc-tab${tab === 'audit' ? ' dc-tab-active' : ''}`, onClick: () => setTab('audit') }, t('audit.title'))),
1330
1728
 
1331
1729
  loading ? h(Spinner, { label: t('dialog.loading') }) : null,
1332
1730
 
1333
- tab === 'trash' && !loading ? h('div', { className: 'dc-panel' },
1731
+ tab === 'trash' && !loading ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1334
1732
  trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1335
1733
  ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1336
1734
  h('div', { className: 'dc-row-main' },
@@ -1347,10 +1745,10 @@ window.__ModuleLoader__.load({
1347
1745
  }),
1348
1746
  h(Button, {
1349
1747
  variant: 'danger',
1350
- disabled: purgePhrase.trim() !== 'DELETE',
1748
+ disabled: purgeBusy === true || purgePhrase.trim() !== 'DELETE',
1351
1749
  onClick: () => { void purge(entry.trashId, purgePhrase.trim()) },
1352
1750
  }, t('action.purge')),
1353
- h(Button, { onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1751
+ h(Button, { disabled: purgeBusy === true, onClick: () => { setPurgeTarget(null); setPurgePhrase('') } }, t('action.cancel')))
1354
1752
  : h(Button, { variant: 'danger', onClick: () => { setPurgeTarget(entry.trashId); setPurgePhrase('') } }, t('action.purge')))))) : null,
1355
1753
 
1356
1754
  tab === 'trash' && !loading && incomplete.length > 0 ? h(IncompleteEntries, {
@@ -1363,9 +1761,10 @@ window.__ModuleLoader__.load({
1363
1761
  onDiscard: (trashId) => { void discard(trashId, discardPhrase.trim()) },
1364
1762
  onRestore: (trashId) => { void restoreIncomplete(trashId) },
1365
1763
  restoreBusyId,
1764
+ discardBusyId,
1366
1765
  }) : null,
1367
1766
 
1368
- tab === 'batch' && !loading ? h('div', { className: 'dc-panel' },
1767
+ tab === 'batch' && !loading ? h('div', { id: 'dc-panel-batch', role: 'tabpanel', 'aria-labelledby': 'dc-tab-batch', className: 'dc-panel' },
1369
1768
  h('div', { className: 'dc-batch-bar' },
1370
1769
  h('span', { className: 'dc-hint' }, t('batch.selected', { count: selectedIds.length })),
1371
1770
  // "Select all" only marks deletable rows: a batch containing one
@@ -1391,7 +1790,7 @@ window.__ModuleLoader__.load({
1391
1790
  onToggle: toggle,
1392
1791
  }))) : null,
1393
1792
 
1394
- tab === 'audit' && !loading ? h('div', { className: 'dc-panel' },
1793
+ tab === 'audit' && !loading ? h('div', { id: 'dc-panel-audit', role: 'tabpanel', 'aria-labelledby': 'dc-tab-audit', className: 'dc-panel' },
1395
1794
  audit.length === 0 ? h('div', { className: 'dc-empty' }, t('audit.empty')) : null,
1396
1795
  ...audit.map((record) => h('div', { key: record.id, className: 'dc-row' },
1397
1796
  h('div', { className: 'dc-row-main' },
@@ -1410,10 +1809,10 @@ window.__ModuleLoader__.load({
1410
1809
  * ------------------------------------------------------------------ */
1411
1810
 
1412
1811
  const CSS = `
1413
- /* 1100 sits above the settings modal (z-index 1000 on body) now that the
1414
- dialog is portaled into the same layer. */
1415
- .dc-backdrop{position:fixed;inset:0;z-index:1100;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
1416
- .dc-dialog{width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
1812
+ /* 1300 clears the settings modal (1000) and the host panels that also use
1813
+ 1100, since this backlog shares document.body with them. */
1814
+ .dc-backdrop{position:fixed;inset:0;z-index:1300;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.38);padding:16px}
1815
+ .dc-dialog{box-sizing:border-box;width:min(560px,94vw);max-height:88vh;overflow:auto;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:12px;box-shadow:0 18px 60px rgba(0,0,0,.28);padding:20px 20px 16px;font:inherit}
1417
1816
  .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
1418
1817
  .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
1419
1818
  .dc-section{margin-top:10px}
@@ -1436,6 +1835,13 @@ window.__ModuleLoader__.load({
1436
1835
  .dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
1437
1836
  .dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
1438
1837
  .dc-input-small{width:90px;height:28px}
1838
+ .dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
1839
+ .dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
1840
+ .dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
1841
+ .dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
1842
+ .dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
1843
+ .dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
1844
+ .dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
1439
1845
  .dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
1440
1846
  .dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
1441
1847
  .dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
@@ -1459,6 +1865,10 @@ window.__ModuleLoader__.load({
1459
1865
  .dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
1460
1866
  .dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
1461
1867
  .dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
1868
+ .dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
1869
+ .dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
1870
+ .dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
1871
+ .dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
1462
1872
  .dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
1463
1873
  .dc-title{font-size:17px;font-weight:600;margin:0}
1464
1874
  .dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
@@ -1479,7 +1889,7 @@ window.__ModuleLoader__.load({
1479
1889
  .dc-row-title{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1480
1890
  .dc-row-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
1481
1891
  .dc-row-time{font-size:11px;color:var(--dsw-alias-label-secondary,#646a73);white-space:nowrap}
1482
- .dc-inline-confirm{display:flex;gap:6px;align-items:center}
1892
+ .dc-inline-confirm{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
1483
1893
  .dc-batch-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
1484
1894
  .dc-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1f2329);border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;box-shadow:0 10px 30px rgba(0,0,0,.22);padding:9px 14px;font-size:12px;max-width:80vw}
1485
1895
  `
@@ -1565,7 +1975,9 @@ window.__ModuleLoader__.load({
1565
1975
  exports.__api = api
1566
1976
  // Direct component handles keep the hook-level behavior testable without a
1567
1977
  // DOM renderer (the registered wrapper alone only builds an element).
1568
- exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow }
1978
+ exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
1979
+ exports.__validateFeedbackInput = validateFeedbackInput
1980
+ exports.__feedbackLimits = FEEDBACK_LIMITS
1569
1981
  exports.__selectableSessionIds = selectableSessionIds
1570
1982
  exports.__adapterLabel = adapterLabel
1571
1983
  exports.__adapterReason = adapterReason