@things-factory/ai-assistant 10.1.214 → 10.1.215

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 (69) hide show
  1. package/client/components/assistant-dock.ts +6 -3
  2. package/client/components/assistant-session-manager.ts +263 -12
  3. package/client/utils/assistant-session-actions.ts +12 -0
  4. package/client/utils/assistant-session-admin.ts +70 -5
  5. package/dist-client/components/assistant-dock.js +6 -3
  6. package/dist-client/components/assistant-dock.js.map +1 -1
  7. package/dist-client/components/assistant-session-manager.d.ts +37 -0
  8. package/dist-client/components/assistant-session-manager.js +281 -13
  9. package/dist-client/components/assistant-session-manager.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-client/utils/assistant-session-actions.d.ts +15 -0
  12. package/dist-client/utils/assistant-session-actions.js +11 -0
  13. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  14. package/dist-client/utils/assistant-session-admin.d.ts +32 -1
  15. package/dist-client/utils/assistant-session-admin.js +36 -5
  16. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  17. package/dist-server/service/assistant-chat-resolver.js +1 -1
  18. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  19. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  20. package/dist-server/service/assistant-mode/index.js +2 -1
  21. package/dist-server/service/assistant-mode/index.js.map +1 -1
  22. package/dist-server/service/assistant-mode/mode-categories.d.ts +15 -0
  23. package/dist-server/service/assistant-mode/mode-categories.js +7 -1
  24. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  25. package/dist-server/service/chat-session/session-cleanup.d.ts +28 -0
  26. package/dist-server/service/chat-session/session-cleanup.js +69 -0
  27. package/dist-server/service/chat-session/session-cleanup.js.map +1 -0
  28. package/dist-server/service/chat-session/session-inbox.d.ts +15 -0
  29. package/dist-server/service/chat-session/session-inbox.js +11 -0
  30. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  31. package/dist-server/service/chat-session/session-purge.d.ts +4 -0
  32. package/dist-server/service/chat-session/session-purge.js +45 -11
  33. package/dist-server/service/chat-session/session-purge.js.map +1 -1
  34. package/dist-server/service/chat-session-resolver.d.ts +24 -2
  35. package/dist-server/service/chat-session-resolver.js +193 -13
  36. package/dist-server/service/chat-session-resolver.js.map +1 -1
  37. package/dist-server/service/data-read/list-read.d.ts +3 -1
  38. package/dist-server/service/data-read/list-read.js +27 -2
  39. package/dist-server/service/data-read/list-read.js.map +1 -1
  40. package/dist-server/service/data-read/read-target.d.ts +16 -0
  41. package/dist-server/service/data-read/read-target.js +18 -1
  42. package/dist-server/service/data-read/read-target.js.map +1 -1
  43. package/dist-server/service/data-read/read-tools.js +3 -1
  44. package/dist-server/service/data-read/read-tools.js.map +1 -1
  45. package/dist-server/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +6 -6
  47. package/server/service/assistant-chat-resolver.ts +2 -2
  48. package/server/service/assistant-mode/conversation-category.test.ts +15 -2
  49. package/server/service/assistant-mode/index.ts +1 -1
  50. package/server/service/assistant-mode/mode-categories.ts +17 -1
  51. package/server/service/assistant-mode/open-subject-door.test.ts +3 -1
  52. package/server/service/chat-session/session-cleanup.test.ts +83 -0
  53. package/server/service/chat-session/session-cleanup.ts +69 -0
  54. package/server/service/chat-session/session-inbox.ts +13 -0
  55. package/server/service/chat-session/session-purge.ts +46 -10
  56. package/server/service/chat-session-resolver.ts +150 -14
  57. package/server/service/data-read/list-read.ts +30 -3
  58. package/server/service/data-read/list-status.test.ts +46 -0
  59. package/server/service/data-read/read-target.ts +32 -1
  60. package/server/service/data-read/read-tools.ts +2 -1
  61. package/test/assistant-dock.test.ts +12 -1
  62. package/test/assistant-session-manager.test.ts +53 -1
  63. package/test/open-doors.test.ts +3 -1
  64. package/translations/en.json +37 -1
  65. package/translations/es.json +37 -1
  66. package/translations/ja.json +37 -1
  67. package/translations/ko.json +37 -1
  68. package/translations/ms.json +37 -1
  69. package/translations/zh.json +37 -1
@@ -521,14 +521,17 @@ export class AssistantDock extends localize(i18next)(LitElement) {
521
521
  if (!userId) throw new Error(t('no-user', 'your sign-in could not be read. sign in again.'))
522
522
 
523
523
  await this.controller.open(this.target(userId, anchor))
524
- await this.takeAsk()
525
524
  } catch (error) {
526
525
  if (ticket === this.revision) this.failure = (error as Error).message
527
526
  } finally {
528
527
  if (ticket === this.revision) {
529
528
  this.loading = false
530
- /* No conversation can stand here - an ask waiting for one is answered now, not sent later somewhere else. */
531
- if (this.anchor && !isAnchor(this.anchor)) void this.takeAsk()
529
+ /*
530
+ * The ask waiting is taken once the opening is done - takeAsk passes while loading, so taking it inside the
531
+ * opening left it waiting for good: a card's 「AI로 고치기」 reopened this screen's conversation and was never sent
532
+ * (2026-10-25). With no conversation possible here, it is answered now, not sent later somewhere else.
533
+ */
534
+ if (this.anchor) void this.takeAsk()
532
535
  /* What was being written on this subject before the page moved away comes back here. */
533
536
  void this.restoreDraft()
534
537
  }
@@ -10,15 +10,24 @@ import { OxPopupMenu } from '@operato/popup/ox-popup-menu.js'
10
10
 
11
11
  import {
12
12
  assistantSessionAdmin,
13
+ bulkActions,
13
14
  sessionActions,
14
15
  type AssistantSessionAdmin,
15
16
  type AssistantSessionEntry,
16
17
  type AssistantSessionFilters,
18
+ type DeletedSessionEntry,
19
+ type SessionKeeping,
17
20
  type SessionVisibility
18
21
  } from '../utils/assistant-session-admin.js'
19
22
 
20
23
  const PAGE = 30
21
24
 
25
+ /**
26
+ * What the list shows (shared-dock.md §2-7): the live ones, the live and the archived, the ones of mine it suggests
27
+ * clearing (picked already, so one press clears them), or the ones I deleted that can still come back.
28
+ */
29
+ type SessionView = 'list' | 'archived' | 'cleanup' | 'deleted'
30
+
22
31
 
23
32
  const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
24
33
  i18next.t(`ai-assistant.session.${key}`, { defaultValue, ...options })
@@ -46,6 +55,10 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
46
55
  * ── Asking before what cannot be undone ─────────────────────────────────────────
47
56
  * Delete, and opening a private conversation to the domain, ask on the row itself - [Cancel][OK].
48
57
  * Making it private, hiding and renaming do not: each can be undone from the same row.
58
+ *
59
+ * ── Many at once (2026-10-25, the user: 「멀티셀렉트로 한번에 지울 수 있게」) ──────────
60
+ * Each row has a box; the picked ones are deleted, left or hidden together, each by its own row's hints
61
+ * (`bulkActions`), with one question. A deleted one is kept for `deletedDays` and comes back from 「Deleted」.
49
62
  */
50
63
  @customElement('ox-assistant-session-manager')
51
64
  export class AssistantSessionManager extends LitElement {
@@ -295,6 +308,38 @@ export class AssistantSessionManager extends LitElement {
295
308
  .hiddenfilter md-icon {
296
309
  --md-icon-size: 16px;
297
310
  }
311
+
312
+ .pick { margin: 0; cursor: pointer; }
313
+ /* The box is small; the space around it picks too - a press beside it must not open the conversation. */
314
+ .pick-area { flex: none; display: inline-flex; align-items: center; align-self: stretch; padding: 0 8px; margin: -7px -8px -7px -10px; padding-left: 10px; cursor: pointer; }
315
+ .pickbar {
316
+ display: flex;
317
+ flex-wrap: wrap;
318
+ align-items: center;
319
+ gap: 4px 10px;
320
+ padding: 6px 10px;
321
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
322
+ font-size: 11px;
323
+ color: var(--md-sys-color-on-surface-variant);
324
+ }
325
+ .pickbar label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
326
+ .pickbar .act,
327
+ .suggest,
328
+ .note .open-link,
329
+ .restore {
330
+ border: 0;
331
+ background: none;
332
+ padding: 0;
333
+ color: var(--md-sys-color-primary);
334
+ font: inherit;
335
+ cursor: pointer;
336
+ }
337
+ .pickbar .act:disabled { color: var(--md-sys-color-on-surface-variant); cursor: default; }
338
+ .pickbar .act.danger { color: var(--md-sys-color-error); }
339
+ .pickbar .unpick { margin-left: auto; }
340
+ .suggest { display: block; width: 100%; padding: 8px 10px; text-align: start; font-size: 12px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
341
+ .restore { flex: none; font-size: 12px; }
342
+ .row.deleted { cursor: default; }
298
343
  `
299
344
 
300
345
  /** The anchor whose conversations are listed - the same one the dock's session controller opened. */
@@ -319,6 +364,18 @@ export class AssistantSessionManager extends LitElement {
319
364
  @state() private loading = false
320
365
  @state() private error = ''
321
366
  @state() private renamingId = ''
367
+ @state() private view: SessionView = 'list'
368
+ @state() private archivedCount = 0
369
+ @state() private cleanupCount = 0
370
+ @state() private keeping: SessionKeeping = { deletedDays: null, archiveDays: null }
371
+ @state() private deleted: DeletedSessionEntry[] = []
372
+ /** The rows picked for doing at once - ids, kept across a reload for the rows still listed. */
373
+ @state() private picked: string[] = []
374
+ @state() private busy = false
375
+ /** What was just done, said once - and the conversation brought back, to open it from here. */
376
+ @state() private notice: { text: string; open?: { sessionId: string; kind?: string } } | null = null
377
+ /** The suggestions arrive picked - set when the view turns to them, spent by the first read. */
378
+ private pickAllOnRead = false
322
379
 
323
380
  /*
324
381
  * The search and the filters stand open together, always (2026-10-12) - a filter behind a "Filters" button is one
@@ -326,7 +383,7 @@ export class AssistantSessionManager extends LitElement {
326
383
  * to choose.
327
384
  */
328
385
  private get filtering() {
329
- return !!this.search.trim() || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
386
+ return !!this.search.trim() || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly || this.view !== 'list'
330
387
  }
331
388
 
332
389
  private clearFilters() {
@@ -336,6 +393,7 @@ export class AssistantSessionManager extends LitElement {
336
393
  this.whose = 'all'
337
394
  this.visibility = 'all'
338
395
  this.includeHidden = this.unreadOnly = false
396
+ this.view = 'list'
339
397
  })
340
398
  }
341
399
 
@@ -354,6 +412,17 @@ export class AssistantSessionManager extends LitElement {
354
412
  this.loading = true
355
413
  this.error = ''
356
414
  try {
415
+ if (this.view === 'deleted') {
416
+ /* The deleted ones are few (kept for deletedDays) - the search narrows them by name here. */
417
+ const word = this.search.trim().toLowerCase()
418
+ const deleted = (await this.admin.deleted(this.anchorType, this.anchorId)).filter(one => !word || (one.name ?? '').toLowerCase().includes(word))
419
+ if (generation !== this.generation) return
420
+ this.deleted = deleted
421
+ this.entries = []
422
+ this.total = deleted.length
423
+ this.keepPicked(deleted.map(one => one.id))
424
+ return
425
+ }
357
426
  /*
358
427
  * Paging reads again from the start rather than appending - live activity reorders the list, and
359
428
  * appending a later page to an earlier order duplicates and drops rows.
@@ -367,7 +436,9 @@ export class AssistantSessionManager extends LitElement {
367
436
  whose: this.whose,
368
437
  visibility: this.visibility,
369
438
  includeHidden: this.includeHidden,
370
- unreadOnly: this.unreadOnly
439
+ unreadOnly: this.unreadOnly,
440
+ includeArchived: this.view === 'archived',
441
+ cleanupOnly: this.view === 'cleanup'
371
442
  },
372
443
  limit: this.pageSize,
373
444
  offset: 0
@@ -376,6 +447,10 @@ export class AssistantSessionManager extends LitElement {
376
447
  this.entries = page.entries
377
448
  this.total = page.total
378
449
  this.hiddenCount = page.hiddenCount
450
+ this.archivedCount = page.archivedCount
451
+ this.cleanupCount = page.cleanupCount
452
+ this.keeping = page.keeping
453
+ this.keepPicked(page.entries.map(entry => entry.id))
379
454
  } catch (error) {
380
455
  if (generation !== this.generation) return
381
456
  this.entries = []
@@ -386,6 +461,27 @@ export class AssistantSessionManager extends LitElement {
386
461
  }
387
462
  }
388
463
 
464
+ /** After a read: the suggestions arrive all picked; otherwise only the picked rows still listed stay picked. */
465
+ private keepPicked(listed: string[]) {
466
+ if (this.pickAllOnRead) {
467
+ this.pickAllOnRead = false
468
+ this.picked = listed
469
+ } else this.picked = this.picked.filter(id => listed.includes(id))
470
+ }
471
+
472
+ private setView(view: SessionView) {
473
+ this.refilter(() => {
474
+ this.view = view
475
+ this.picked = []
476
+ this.notice = null
477
+ this.pickAllOnRead = view === 'cleanup'
478
+ })
479
+ }
480
+
481
+ private togglePick(id: string, on: boolean) {
482
+ this.picked = on ? [...new Set([...this.picked, id])] : this.picked.filter(one => one !== id)
483
+ }
484
+
389
485
  protected updated(changed: PropertyValues) {
390
486
  if (changed.has('anchorType') || changed.has('anchorId') || changed.has('admin') || changed.has('kind')) void this.reload()
391
487
 
@@ -437,6 +533,7 @@ export class AssistantSessionManager extends LitElement {
437
533
  /** Runs one door, then reads the list again - on success and on failure alike, so the row shows the truth. */
438
534
  private async act(entry: AssistantSessionEntry, change: string, run: () => Promise<boolean>, refused: string) {
439
535
  this.renamingId = ''
536
+ this.notice = null
440
537
  try {
441
538
  if (!(await run())) this.error = refused
442
539
  else this.emit('assistant-session-changed', { sessionId: entry.id, change })
@@ -473,6 +570,75 @@ export class AssistantSessionManager extends LitElement {
473
570
  void this.act(entry, 'removed', () => this.admin.remove(entry.id), t('remove-refused', 'this conversation could not be deleted.'))
474
571
  }
475
572
 
573
+ /**
574
+ * Runs one door for each picked row, then reads the list again. Every success is told to the host like a row's own
575
+ * change, so it moves off an open conversation that went; every refusal is said by name.
576
+ */
577
+ private async runMany(ids: string[], change: string, run: (id: string) => Promise<boolean>, done: (count: number) => string) {
578
+ if (!ids.length || this.busy) return
579
+ this.busy = true
580
+ this.error = ''
581
+ this.notice = null
582
+ const names = new Map([...this.entries.map(entry => [entry.id, entry.name] as const), ...this.deleted.map(one => [one.id, one.name] as const)])
583
+ const refused: string[] = []
584
+ let count = 0
585
+ for (const id of ids) {
586
+ try {
587
+ if (await run(id)) {
588
+ count++
589
+ this.emit('assistant-session-changed', { sessionId: id, change })
590
+ } else refused.push(names.get(id) || t('untitled', 'Untitled conversation'))
591
+ } catch (error) {
592
+ refused.push(`${names.get(id) || t('untitled', 'Untitled conversation')} (${(error as Error).message})`)
593
+ }
594
+ }
595
+ this.picked = []
596
+ this.busy = false
597
+ const error = refused.length ? t('some-refused', 'Not done for {count}: {names}', { count: refused.length, names: refused.join(', ') }) : ''
598
+ await this.reload()
599
+ if (count) this.notice = { text: done(count) }
600
+ if (error) this.error = error
601
+ }
602
+
603
+ /** The sentence that says a deleted conversation can still come back - from 「Deleted」, for as long as it is kept. */
604
+ private keptFor(): string {
605
+ return this.keeping.deletedDays
606
+ ? t('kept-for', 'You can bring it back from Deleted for {days} days.', { days: this.keeping.deletedDays })
607
+ : t('kept', 'You can bring it back from Deleted.')
608
+ }
609
+
610
+ private async askRemoveMany(ids: string[]) {
611
+ const ok = await this.ask(
612
+ t('remove', 'Delete'),
613
+ `${t('confirm-remove-many', 'Delete {count} conversations? They disappear for every participant.', { count: ids.length })} ${this.keptFor()}`,
614
+ t('remove', 'Delete')
615
+ )
616
+ if (ok) void this.runMany(ids, 'removed', id => this.admin.remove(id), count => t('removed-many', '{count} deleted.', { count }))
617
+ }
618
+
619
+ private async askLeaveMany(ids: string[]) {
620
+ const ok = await this.ask(
621
+ t('leave', 'Leave'),
622
+ t('confirm-leave-many', 'Leave {count} conversations? They stop coming to your list, and a group or private one is read again only when its owner brings you back.', { count: ids.length }),
623
+ t('leave', 'Leave')
624
+ )
625
+ if (ok) void this.runMany(ids, 'left', id => this.admin.leave(id), count => t('left-many', 'Left {count}.', { count }))
626
+ }
627
+
628
+ private hideMany(ids: string[]) {
629
+ void this.runMany(ids, 'hidden', id => this.admin.hide(id, true), count => t('hidden-many', '{count} hidden from your list.', { count }))
630
+ }
631
+
632
+ private restoreMany(ids: string[]) {
633
+ void this.runMany(ids, 'restored', id => this.admin.restore(id), count => t('restored-many', '{count} brought back.', { count }))
634
+ }
635
+
636
+ private async restoreOne(one: DeletedSessionEntry) {
637
+ const name = one.name || t('untitled', 'Untitled conversation')
638
+ await this.runMany([one.id], 'restored', id => this.admin.restore(id), () => t('restored', '"{name}" is back.', { name }))
639
+ if (this.notice) this.notice = { ...this.notice, open: { sessionId: one.id, kind: one.kind } }
640
+ }
641
+
476
642
  /* ── drawing ─────────────────────────────────────────────────────────────────────── */
477
643
 
478
644
  render(): TemplateResult {
@@ -490,7 +656,7 @@ export class AssistantSessionManager extends LitElement {
490
656
  <button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
491
657
  </div>
492
658
  <div class="filters">
493
- <select aria-label=${t('filter-whose', 'Whose')} .value=${this.whose}
659
+ ${this.view === 'deleted' ? nothing : html`<select aria-label=${t('filter-whose', 'Whose')} .value=${this.whose}
494
660
  @change=${(e: Event) => this.refilter(() => (this.whose = (e.target as HTMLSelectElement).value as any))}>
495
661
  <option value="all">${t('filter-all', 'All')}</option>
496
662
  <option value="mine">${t('filter-mine', 'Started by me')}</option>
@@ -502,32 +668,115 @@ export class AssistantSessionManager extends LitElement {
502
668
  <option value="open">${t('filter-open', 'Public')}</option>
503
669
  <option value="group">${t('filter-group', 'Group')}</option>
504
670
  <option value="private">${t('filter-private', 'Private')}</option>
671
+ </select>`}
672
+ <select aria-label=${t('filter-view', 'Show')} .value=${this.view}
673
+ @change=${(e: Event) => this.setView((e.target as HTMLSelectElement).value as SessionView)}>
674
+ <option value="list">${t('view-list', 'Active')}</option>
675
+ <option value="archived">${t('view-archived', 'Including archived')}</option>
676
+ <option value="cleanup">${t('view-cleanup', 'Suggested to clear')}</option>
677
+ <option value="deleted">${t('view-deleted', 'Deleted')}</option>
505
678
  </select>
506
- <label><input type="checkbox" .checked=${this.unreadOnly}
679
+ ${this.view === 'deleted' ? nothing : html`<label><input type="checkbox" .checked=${this.unreadOnly}
507
680
  @change=${(e: Event) => this.refilter(() => (this.unreadOnly = (e.target as HTMLInputElement).checked))} />${t('filter-unread', 'Unread')}</label>
508
681
  <label><input type="checkbox" .checked=${this.includeHidden}
509
- @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
682
+ @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>`}
510
683
  ${this.filtering ? html`<button class="clear" @click=${() => this.clearFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
511
684
  </div>
685
+ ${this.renderPickBar()}
512
686
  <div class="list">
513
687
  ${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
688
+ ${this.notice
689
+ ? html`<div class="note" role="status">${this.notice.text}${this.notice.open
690
+ ? html` <button class="open-link" @click=${() => this.emit('assistant-session-select', this.notice!.open)}>${t('open', 'Open')}</button>`
691
+ : nothing}</div>`
692
+ : nothing}
514
693
  ${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
515
- ${shown === 0 && !this.loading && !this.error
516
- ? html`<div class="empty" role="status">${this.filtering ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations yet.')}</div>`
517
- : this.entries.map(entry => this.renderRow(entry))}
518
- ${shown < this.total
694
+ ${this.view === 'list' && this.cleanupCount > 0 && !this.search.trim()
695
+ ? html`<button class="suggest" @click=${() => this.setView('cleanup')}>${t('cleanup-suggest', '{count} of yours look ready to clear - see them', { count: this.cleanupCount })}</button>`
696
+ : nothing}
697
+ ${this.view === 'deleted'
698
+ ? this.deleted.length === 0 && !this.loading && !this.error
699
+ ? html`<div class="empty" role="status">${t('deleted-empty', 'Nothing you deleted here is kept.')}</div>`
700
+ : this.deleted.map(one => this.renderDeletedRow(one))
701
+ : shown === 0 && !this.loading && !this.error
702
+ ? html`<div class="empty" role="status">${this.view === 'cleanup'
703
+ ? t('cleanup-empty', 'Nothing of yours to clear here.')
704
+ : this.filtering ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations yet.')}</div>`
705
+ : this.entries.map(entry => this.renderRow(entry))}
706
+ ${this.view !== 'deleted' && shown < this.total
519
707
  ? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
520
708
  : nothing}
521
709
  </div>
522
710
  <!-- A hidden conversation must not be forgotten: say how many, and one press shows them. -->
523
- ${this.hiddenCount > 0 && !this.includeHidden
711
+ ${this.hiddenCount > 0 && !this.includeHidden && (this.view === 'list' || this.view === 'archived')
524
712
  ? html`<button class="hiddenfilter" @click=${() => this.refilter(() => (this.includeHidden = true))}>
525
713
  <md-icon>visibility_off</md-icon>${t('hidden-count', '{count} hidden', { count: this.hiddenCount })}
526
714
  </button>`
527
715
  : nothing}
716
+ ${this.archivedCount > 0 && this.view === 'list'
717
+ ? html`<button class="hiddenfilter" @click=${() => this.setView('archived')}>
718
+ <md-icon>inventory_2</md-icon>${t('archived-count', '{count} archived - no activity for {days} days', { count: this.archivedCount, days: this.keeping.archiveDays ?? '' })}
719
+ </button>`
720
+ : nothing}
528
721
  `
529
722
  }
530
723
 
724
+ /** The box over the list: pick all, and what the picked ones can have done to them together. */
725
+ private renderPickBar() {
726
+ const rows: { id: string }[] = this.view === 'deleted' ? this.deleted : this.entries
727
+ if (!rows.length) return nothing
728
+ const picked = this.picked.length
729
+ const all = picked === rows.length
730
+ const toggleAll = (on: boolean) => (this.picked = on ? rows.map(row => row.id) : [])
731
+ const label = picked ? t('picked', '{count} picked', { count: picked }) : t('pick-all', 'Pick all')
732
+ if (this.view === 'deleted') {
733
+ return html`<div class="pickbar">
734
+ <label><input type="checkbox" .checked=${all} .indeterminate=${picked > 0 && !all} @change=${(e: Event) => toggleAll((e.target as HTMLInputElement).checked)} />${label}</label>
735
+ ${picked
736
+ ? html`<button class="act" ?disabled=${this.busy} @click=${() => this.restoreMany(this.picked)}>${t('restore-many', 'Bring back {count}', { count: picked })}</button>
737
+ <button class="act unpick" @click=${() => (this.picked = [])}>${t('unpick', 'Clear the pick')}</button>`
738
+ : nothing}
739
+ </div>`
740
+ }
741
+ const can = bulkActions(this.entries.filter(entry => this.picked.includes(entry.id)))
742
+ return html`<div class="pickbar">
743
+ <label><input type="checkbox" .checked=${all} .indeterminate=${picked > 0 && !all} @change=${(e: Event) => toggleAll((e.target as HTMLInputElement).checked)} />${label}</label>
744
+ ${picked
745
+ ? html`${can.remove.length
746
+ ? html`<button class="act danger" ?disabled=${this.busy} @click=${() => this.askRemoveMany(can.remove)}>${t('remove-many', 'Delete {count}', { count: can.remove.length })}</button>`
747
+ : nothing}
748
+ ${can.leave.length
749
+ ? html`<button class="act" ?disabled=${this.busy} @click=${() => this.askLeaveMany(can.leave)}>${t('leave-many', 'Leave {count}', { count: can.leave.length })}</button>`
750
+ : nothing}
751
+ ${can.hide.length
752
+ ? html`<button class="act" ?disabled=${this.busy} @click=${() => this.hideMany(can.hide)}>${t('hide-many', 'Hide {count}', { count: can.hide.length })}</button>`
753
+ : nothing}
754
+ <button class="act unpick" @click=${() => (this.picked = [])}>${t('unpick', 'Clear the pick')}</button>`
755
+ : nothing}
756
+ </div>`
757
+ }
758
+
759
+ private renderDeletedRow(one: DeletedSessionEntry): TemplateResult {
760
+ const name = one.name || t('untitled', 'Untitled conversation')
761
+ const day = (at?: string) => (at && Number.isFinite(Date.parse(at)) ? new Intl.DateTimeFormat(i18next.language || 'en', { month: 'short', day: 'numeric' }).format(new Date(at)) : '')
762
+ return html`<div class="row deleted">
763
+ <label class="pick-area"><input type="checkbox" class="pick" aria-label=${t('pick', 'Pick {name}', { name })} .checked=${this.picked.includes(one.id)}
764
+ @change=${(e: Event) => this.togglePick(one.id, (e.target as HTMLInputElement).checked)} /></label>
765
+ <div class="open-row">
766
+ <div class="title">${name}</div>
767
+ <span class="meta">${t('deleted-on', 'Deleted {date}', { date: day(one.deletedAt) })}${one.purgesAt ? html` · ${t('purges-on', 'emptied {date}', { date: day(one.purgesAt) })}` : nothing}</span>
768
+ </div>
769
+ <button class="restore" ?disabled=${this.busy} @click=${() => this.restoreOne(one)}>${t('restore', 'Bring back')}</button>
770
+ </div>`
771
+ }
772
+
773
+ /** Why a row is where it is: archived, or suggested for clearing. */
774
+ private renderState(entry: AssistantSessionEntry) {
775
+ if (entry.cleanupReason === 'empty') return html` · ${t('reason-empty', 'Never asked anything')}`
776
+ if (entry.cleanupReason === 'inactive') return html` · ${t('reason-inactive', 'No activity for {days} days', { days: entry.idleDays ?? '' })}`
777
+ return entry.archived ? html` · ${t('archived', 'Archived')}` : nothing
778
+ }
779
+
531
780
  private renderRow(entry: AssistantSessionEntry): TemplateResult {
532
781
  const name = entry.name || t('untitled', 'Untitled conversation')
533
782
  const actions = sessionActions(entry)
@@ -552,11 +801,13 @@ export class AssistantSessionManager extends LitElement {
552
801
  return html`<div
553
802
  class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''} ${isOpen ? 'open-audience' : ''}"
554
803
  >
804
+ <label class="pick-area"><input type="checkbox" class="pick" aria-label=${t('pick', 'Pick {name}', { name })} .checked=${this.picked.includes(entry.id)}
805
+ @change=${(e: Event) => this.togglePick(entry.id, (e.target as HTMLInputElement).checked)} /></label>
555
806
  <span class="dot"></span>
556
807
  <button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
557
808
  <div class="title">${name}</div>
558
809
  <div class="prev">${entry.lastMessagePreview || ''}</div>
559
- <span class="meta">${this.renderAudience(entry.visibility)}${entry.lastMessageAt && Number.isFinite(Date.parse(entry.lastMessageAt)) ? html` · <time datetime=${entry.lastMessageAt}>${new Intl.DateTimeFormat(i18next.language || 'en', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(entry.lastMessageAt))}</time>` : nothing}${entry.mine ? html` · ${t('filter-mine', 'Started by me')}` : nothing}</span>
810
+ <span class="meta">${this.renderAudience(entry.visibility)}${entry.lastMessageAt && Number.isFinite(Date.parse(entry.lastMessageAt)) ? html` · <time datetime=${entry.lastMessageAt}>${new Intl.DateTimeFormat(i18next.language || 'en', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(entry.lastMessageAt))}</time>` : nothing}${entry.mine ? html` · ${t('filter-mine', 'Started by me')}` : nothing}${this.renderState(entry)}</span>
560
811
  </button>
561
812
  <button
562
813
  class="more-menu"
@@ -613,7 +864,7 @@ export class AssistantSessionManager extends LitElement {
613
864
  private async askRemove(entry: AssistantSessionEntry, name: string) {
614
865
  const ok = await this.ask(
615
866
  t('remove', 'Delete'),
616
- t('confirm-remove', 'Delete "{name}"? It disappears for every participant and cannot be brought back.', { name }),
867
+ `${t('confirm-remove', 'Delete "{name}"? It disappears for every participant.', { name })} ${this.keptFor()}`,
617
868
  t('remove', 'Delete')
618
869
  )
619
870
  if (ok) this._removeSession(entry)
@@ -18,3 +18,15 @@ export function sessionActions(entry: { participant?: boolean; hidden?: boolean;
18
18
  leave: !!entry.participant && !entry.owner
19
19
  }
20
20
  }
21
+
22
+ /**
23
+ * What a picked set of rows can have done to it at once - each row by the same hints as its own menu: delete the
24
+ * ones I started, leave the ones I am only in, hide any (2026-10-25, the user: 「멀티셀렉트로 한번에 지울 수 있게」).
25
+ */
26
+ export function bulkActions(entries: { id: string; participant?: boolean; hidden?: boolean; mine?: boolean; owner?: boolean }[]) {
27
+ return {
28
+ remove: entries.filter(entry => sessionActions(entry).remove).map(entry => entry.id),
29
+ leave: entries.filter(entry => !sessionActions(entry).remove && sessionActions(entry).leave).map(entry => entry.id),
30
+ hide: entries.filter(entry => sessionActions(entry).hide).map(entry => entry.id)
31
+ }
32
+ }
@@ -2,7 +2,7 @@ import { client } from '@operato/graphql'
2
2
  import { i18next } from '@operato/i18n'
3
3
  import gql from 'graphql-tag'
4
4
 
5
- export { sessionActions } from './assistant-session-actions.js'
5
+ export { bulkActions, sessionActions } from './assistant-session-actions.js'
6
6
 
7
7
  /**
8
8
  * Managing the conversations on one anchor - list, rename, hide, delete, and who may read them.
@@ -18,6 +18,7 @@ export { sessionActions } from './assistant-session-actions.js'
18
18
  * delete the creator (`mine`) (not reversible from a screen)
19
19
  * visibility the owner (`owner`) (opening shows what is already written)
20
20
  * leave a member, never the owner (in a group, leaving ends reading here)
21
+ * restore the creator, while it is kept (deleted ones are kept `deletedDays`, shared-dock.md §9-5)
21
22
  */
22
23
 
23
24
  /** Who reads it: everyone in the domain, the people the owner brought in, or the owner alone (ADR-0073 decision 3). */
@@ -40,6 +41,27 @@ export interface AssistantSessionEntry {
40
41
  /** Hidden from my own list (returned only with the hidden filter on). */
41
42
  hidden?: boolean
42
43
  visibility?: SessionVisibility | string
44
+ /** No activity for the archive period - out of the list until asked for or searched. */
45
+ archived?: boolean
46
+ /** Why the list suggests clearing it (mine only): never asked anything, or idle for 30 days. */
47
+ cleanupReason?: 'empty' | 'inactive' | string
48
+ /** Whole days since the last activity. */
49
+ idleDays?: number
50
+ }
51
+
52
+ /** A conversation I deleted that is still kept - it can be brought back until `purgesAt`. */
53
+ export interface DeletedSessionEntry {
54
+ id: string
55
+ name?: string
56
+ kind?: string
57
+ deletedAt: string
58
+ purgesAt?: string
59
+ }
60
+
61
+ /** How long conversations are kept; `null` when that keeping is off. */
62
+ export interface SessionKeeping {
63
+ deletedDays: number | null
64
+ archiveDays: number | null
43
65
  }
44
66
 
45
67
  /** Legacy single filter - kept for callers written before the filter set. */
@@ -53,6 +75,10 @@ export interface AssistantSessionFilters {
53
75
  visibility?: 'all' | SessionVisibility
54
76
  includeHidden?: boolean
55
77
  unreadOnly?: boolean
78
+ /** Also the archived ones. */
79
+ includeArchived?: boolean
80
+ /** Only my own the list suggests clearing - hidden and archived ones included. */
81
+ cleanupOnly?: boolean
56
82
  }
57
83
 
58
84
  export interface AssistantSessionQuery {
@@ -71,6 +97,11 @@ export interface AssistantSessionPage {
71
97
  total: number
72
98
  /** How many I hid - so a hidden conversation is never forgotten. */
73
99
  hiddenCount: number
100
+ /** How many are archived - the same, for the ones left idle. */
101
+ archivedCount: number
102
+ /** How many of mine the list suggests clearing. */
103
+ cleanupCount: number
104
+ keeping: SessionKeeping
74
105
  }
75
106
 
76
107
  export interface AssistantSessionAdmin {
@@ -80,6 +111,8 @@ export interface AssistantSessionAdmin {
80
111
  remove(sessionId: string): Promise<boolean>
81
112
  setVisibility(sessionId: string, visibility: SessionVisibility): Promise<boolean>
82
113
  leave(sessionId: string): Promise<boolean>
114
+ deleted(anchorType: string, anchorId: string): Promise<DeletedSessionEntry[]>
115
+ restore(sessionId: string): Promise<boolean>
83
116
  }
84
117
 
85
118
  /** The page size the server allows at most (`chatSessionInbox` clamps to 200). */
@@ -98,10 +131,13 @@ export const assistantSessionAdmin: AssistantSessionAdmin = {
98
131
  const mine = filters.whose ? filters.whose === 'mine' : filter === 'mine'
99
132
  const result = await client.query({
100
133
  query: gql`
101
- query ($t: String!, $i: String!, $h: Boolean, $q: String, $m: Boolean, $l: Int, $o: Int, $k: String, $p: Boolean, $v: String, $u: Boolean) {
134
+ query ($t: String!, $i: String!, $h: Boolean, $q: String, $m: Boolean, $l: Int, $o: Int, $k: String, $p: Boolean, $v: String, $u: Boolean, $a: Boolean, $c: Boolean) {
102
135
  hiddenSessionCount(anchorType: $t, anchorId: $i)
103
- chatSessionInboxTotal(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u)
104
- chatSessionInbox(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u, limit: $l, offset: $o) {
136
+ archivedSessionCount(anchorType: $t, anchorId: $i)
137
+ cleanupSessionCount(anchorType: $t, anchorId: $i)
138
+ aiSessionKeeping { deletedDays archiveDays }
139
+ chatSessionInboxTotal(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u, includeArchived: $a, cleanupOnly: $c)
140
+ chatSessionInbox(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u, includeArchived: $a, cleanupOnly: $c, limit: $l, offset: $o) {
105
141
  id
106
142
  name
107
143
  kind
@@ -114,6 +150,9 @@ export const assistantSessionAdmin: AssistantSessionAdmin = {
114
150
  owner
115
151
  hidden
116
152
  visibility
153
+ archived
154
+ cleanupReason
155
+ idleDays
117
156
  }
118
157
  }
119
158
  `,
@@ -128,6 +167,8 @@ export const assistantSessionAdmin: AssistantSessionAdmin = {
128
167
  p: filters.whose === 'participant' || null,
129
168
  v: filters.visibility && filters.visibility !== 'all' ? filters.visibility : null,
130
169
  u: filters.unreadOnly || null,
170
+ a: filters.includeArchived || null,
171
+ c: filters.cleanupOnly || null,
131
172
  l: Math.min(SESSION_PAGE_MAX, Math.max(1, limit)),
132
173
  o: Math.max(0, offset)
133
174
  },
@@ -144,7 +185,13 @@ export const assistantSessionAdmin: AssistantSessionAdmin = {
144
185
  /* With the hidden filter the server returns hidden ones alongside the rest, marked - the total counts both. */
145
186
  entries,
146
187
  total: result.data?.chatSessionInboxTotal ?? entries.length,
147
- hiddenCount: result.data?.hiddenSessionCount ?? 0
188
+ hiddenCount: result.data?.hiddenSessionCount ?? 0,
189
+ archivedCount: result.data?.archivedSessionCount ?? 0,
190
+ cleanupCount: result.data?.cleanupSessionCount ?? 0,
191
+ keeping: {
192
+ deletedDays: result.data?.aiSessionKeeping?.deletedDays ?? null,
193
+ archiveDays: result.data?.aiSessionKeeping?.archiveDays ?? null
194
+ }
148
195
  }
149
196
  },
150
197
 
@@ -170,5 +217,23 @@ export const assistantSessionAdmin: AssistantSessionAdmin = {
170
217
 
171
218
  leave(sessionId) {
172
219
  return mutate(gql`mutation ($id: String!) { leaveAISession(sessionId: $id) }`, { id: sessionId }, 'leaveAISession')
220
+ },
221
+
222
+ async deleted(anchorType, anchorId) {
223
+ const result = await client.query({
224
+ query: gql`
225
+ query ($t: String!, $i: String!) {
226
+ deletedAISessions(anchorType: $t, anchorId: $i) { id name kind deletedAt purgesAt }
227
+ }
228
+ `,
229
+ variables: { t: anchorType, i: anchorId },
230
+ fetchPolicy: 'no-cache'
231
+ })
232
+ if (result.errors?.length) throw new Error(result.errors[0].message)
233
+ return result.data?.deletedAISessions ?? []
234
+ },
235
+
236
+ restore(sessionId) {
237
+ return mutate(gql`mutation ($id: String!) { restoreAISession(sessionId: $id) }`, { id: sessionId }, 'restoreAISession')
173
238
  }
174
239
  }
@@ -535,7 +535,6 @@ let AssistantDock = class AssistantDock extends localize(i18next)(LitElement) {
535
535
  if (!userId)
536
536
  throw new Error(t('no-user', 'your sign-in could not be read. sign in again.'));
537
537
  await this.controller.open(this.target(userId, anchor));
538
- await this.takeAsk();
539
538
  }
540
539
  catch (error) {
541
540
  if (ticket === this.revision)
@@ -544,8 +543,12 @@ let AssistantDock = class AssistantDock extends localize(i18next)(LitElement) {
544
543
  finally {
545
544
  if (ticket === this.revision) {
546
545
  this.loading = false;
547
- /* No conversation can stand here - an ask waiting for one is answered now, not sent later somewhere else. */
548
- if (this.anchor && !isAnchor(this.anchor))
546
+ /*
547
+ * The ask waiting is taken once the opening is done - takeAsk passes while loading, so taking it inside the
548
+ * opening left it waiting for good: a card's 「AI로 고치기」 reopened this screen's conversation and was never sent
549
+ * (2026-10-25). With no conversation possible here, it is answered now, not sent later somewhere else.
550
+ */
551
+ if (this.anchor)
549
552
  void this.takeAsk();
550
553
  /* What was being written on this subject before the page moved away comes back here. */
551
554
  void this.restoreDraft();