@things-factory/ai-assistant 10.1.213 → 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 (89) hide show
  1. package/client/components/assistant-dock.ts +6 -3
  2. package/client/components/assistant-selection-bar.test.ts +42 -0
  3. package/client/components/assistant-selection-bar.ts +129 -0
  4. package/client/components/assistant-session-manager.ts +263 -12
  5. package/client/index.ts +2 -0
  6. package/client/utils/assistant-selection.test.ts +25 -1
  7. package/client/utils/assistant-selection.ts +27 -0
  8. package/client/utils/assistant-session-actions.ts +12 -0
  9. package/client/utils/assistant-session-admin.ts +70 -5
  10. package/dist-client/components/assistant-dock.js +6 -3
  11. package/dist-client/components/assistant-dock.js.map +1 -1
  12. package/dist-client/components/assistant-selection-bar.d.ts +44 -0
  13. package/dist-client/components/assistant-selection-bar.js +149 -0
  14. package/dist-client/components/assistant-selection-bar.js.map +1 -0
  15. package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
  16. package/dist-client/components/assistant-selection-bar.test.js +38 -0
  17. package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
  18. package/dist-client/components/assistant-session-manager.d.ts +37 -0
  19. package/dist-client/components/assistant-session-manager.js +281 -13
  20. package/dist-client/components/assistant-session-manager.js.map +1 -1
  21. package/dist-client/index.d.ts +1 -0
  22. package/dist-client/index.js +2 -0
  23. package/dist-client/index.js.map +1 -1
  24. package/dist-client/tsconfig.tsbuildinfo +1 -1
  25. package/dist-client/utils/assistant-selection.d.ts +24 -0
  26. package/dist-client/utils/assistant-selection.js +24 -0
  27. package/dist-client/utils/assistant-selection.js.map +1 -1
  28. package/dist-client/utils/assistant-selection.test.js +21 -1
  29. package/dist-client/utils/assistant-selection.test.js.map +1 -1
  30. package/dist-client/utils/assistant-session-actions.d.ts +15 -0
  31. package/dist-client/utils/assistant-session-actions.js +11 -0
  32. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  33. package/dist-client/utils/assistant-session-admin.d.ts +32 -1
  34. package/dist-client/utils/assistant-session-admin.js +36 -5
  35. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  36. package/dist-server/service/assistant-chat-resolver.js +6 -4
  37. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  38. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  39. package/dist-server/service/assistant-mode/index.js +2 -1
  40. package/dist-server/service/assistant-mode/index.js.map +1 -1
  41. package/dist-server/service/assistant-mode/mode-categories.d.ts +30 -0
  42. package/dist-server/service/assistant-mode/mode-categories.js +35 -2
  43. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  44. package/dist-server/service/chat-session/session-cleanup.d.ts +28 -0
  45. package/dist-server/service/chat-session/session-cleanup.js +69 -0
  46. package/dist-server/service/chat-session/session-cleanup.js.map +1 -0
  47. package/dist-server/service/chat-session/session-inbox.d.ts +15 -0
  48. package/dist-server/service/chat-session/session-inbox.js +11 -0
  49. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  50. package/dist-server/service/chat-session/session-purge.d.ts +4 -0
  51. package/dist-server/service/chat-session/session-purge.js +45 -11
  52. package/dist-server/service/chat-session/session-purge.js.map +1 -1
  53. package/dist-server/service/chat-session-resolver.d.ts +24 -2
  54. package/dist-server/service/chat-session-resolver.js +193 -13
  55. package/dist-server/service/chat-session-resolver.js.map +1 -1
  56. package/dist-server/service/data-read/list-read.d.ts +3 -1
  57. package/dist-server/service/data-read/list-read.js +27 -2
  58. package/dist-server/service/data-read/list-read.js.map +1 -1
  59. package/dist-server/service/data-read/read-target.d.ts +16 -0
  60. package/dist-server/service/data-read/read-target.js +18 -1
  61. package/dist-server/service/data-read/read-target.js.map +1 -1
  62. package/dist-server/service/data-read/read-tools.js +3 -1
  63. package/dist-server/service/data-read/read-tools.js.map +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +6 -6
  66. package/server/service/assistant-chat-resolver.ts +7 -5
  67. package/server/service/assistant-mode/conversation-category.test.ts +15 -2
  68. package/server/service/assistant-mode/index.ts +1 -1
  69. package/server/service/assistant-mode/mode-categories.ts +48 -2
  70. package/server/service/assistant-mode/mode-front.test.ts +27 -1
  71. package/server/service/assistant-mode/open-subject-door.test.ts +3 -1
  72. package/server/service/chat-session/session-cleanup.test.ts +83 -0
  73. package/server/service/chat-session/session-cleanup.ts +69 -0
  74. package/server/service/chat-session/session-inbox.ts +13 -0
  75. package/server/service/chat-session/session-purge.ts +46 -10
  76. package/server/service/chat-session-resolver.ts +150 -14
  77. package/server/service/data-read/list-read.ts +30 -3
  78. package/server/service/data-read/list-status.test.ts +46 -0
  79. package/server/service/data-read/read-target.ts +32 -1
  80. package/server/service/data-read/read-tools.ts +2 -1
  81. package/test/assistant-dock.test.ts +12 -1
  82. package/test/assistant-session-manager.test.ts +53 -1
  83. package/test/open-doors.test.ts +3 -1
  84. package/translations/en.json +45 -2
  85. package/translations/es.json +45 -2
  86. package/translations/ja.json +45 -2
  87. package/translations/ko.json +45 -2
  88. package/translations/ms.json +45 -2
  89. package/translations/zh.json +45 -2
@@ -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
  }
@@ -0,0 +1,42 @@
1
+ /*
2
+ * 「AI와 작업」 over a list (ox-assistant-selection-bar) - one bar for every application's lists. What it is held to here is
3
+ * its source and its words; its behaviour on a real grid is held by the applications' browser tests (operato-office
4
+ * test/browser/page-ai-input.test.js).
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ const bare = readFileSync(join(__dirname, 'assistant-selection-bar.ts'), 'utf-8').replace(/\/\*[\s\S]*?\*\//g, '')
10
+
11
+ describe('the selection bar', () => {
12
+ it('★ counts every way a grid picks - a row, the header\'s select-all, a new page of rows', () => {
13
+ expect(bare).toMatch(/GRID_EVENTS = \['select-record-change', 'select-all-change', 'list-adopted'\]/)
14
+ })
15
+
16
+ it('★ asks for its grid when it needs it - a page finds its grid only after its first render', () => {
17
+ expect(bare).toMatch(/gridOf\?: \(\) => SelectionGrid \| undefined/)
18
+ expect(bare).not.toMatch(/@property\(\{ attribute: false \}\) grist/)
19
+ })
20
+
21
+ it('★ hands the rows as keys of the list, to this screen\'s conversation, sent at once', () => {
22
+ expect(bare).toMatch(/selection: \{ title: this\.name, list: this\.list, records: selectedRecords\(rows, \{ axis: this\.axis, labelOf: this\.labelOf \}\) \}/)
23
+ expect(bare).toMatch(/ask: request\?\.trim\(\) \|\| i18next\.t\('ai-assistant\.selection\.ask'/)
24
+ expect(bare).not.toMatch(/draft|continueCurrent|transcript/)
25
+ })
26
+
27
+ it('keeps the words unless the request was sent', () => {
28
+ expect(bare).toMatch(/if \(outcome\?\.sent && this\.request === request\) this\.request = ''/)
29
+ })
30
+
31
+ it('every word it shows reads in every language', () => {
32
+ const keys = [...new Set([...bare.matchAll(/'(ai-assistant\.selection\.[\w-]+)'/g)].map(([, key]) => key))]
33
+ expect(keys.length).toBe(7)
34
+ for (const locale of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
35
+ const words = JSON.parse(readFileSync(join(__dirname, `../../translations/${locale}.json`), 'utf-8'))
36
+ for (const key of keys) expect([locale, key, typeof words[key]]).toEqual([locale, key, 'string'])
37
+ expect(words['ai-assistant.selection.ask']).toMatch(/\{n\}/)
38
+ expect(words['ai-assistant.selection.ask']).toMatch(/\{title\}/)
39
+ expect(words['ai-assistant.selection.count']).toMatch(/\{n\}/)
40
+ }
41
+ })
42
+ })
@@ -0,0 +1,129 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+
4
+ import { i18next } from '@operato/i18n'
5
+ import { notify } from '@operato/layout'
6
+ import { ButtonStyles, FormFieldStyles } from '@operato/styles'
7
+
8
+ import { askAssistant } from '../utils/assistant-ask.js'
9
+ import { selectedRecords } from '../utils/assistant-selection.js'
10
+
11
+ /** What a grid says when its picks or its rows change - a row's picker, the header's select-all, a new page of rows. */
12
+ const GRID_EVENTS = ['select-record-change', 'select-all-change', 'list-adopted']
13
+
14
+ /** The part of a grid (data-grist `ox-grist`) the bar reads. */
15
+ export interface SelectionGrid extends HTMLElement {
16
+ selected?: any[]
17
+ updateComplete?: Promise<unknown>
18
+ }
19
+
20
+ /**
21
+ * 「AI와 작업」 over a list - one bar for every application's lists (design/ai/shared-dock.md §2-11-1). While rows are
22
+ * picked it shows how many, what the list itself does with them, and a request sent to this screen's conversation with
23
+ * the rows as the selection. Begun on the Office ledgers; the user, 2026-10-25: 「'AI와 작업' 기능을 사용하려면, 그리드
24
+ * 선택기능이 필요한데, 주요 리스트 화면에서 선택기능이 없는 경우가 꽤 있다」, and it is to serve plant, warehouse and twin.
25
+ *
26
+ * A screen gives the grid (asked for when needed - a page finds its grid only after its first render), the list as the
27
+ * person knows it, the list query and its axis (the last part of the list's read key, so a row goes as `<axis>:<uuid>`),
28
+ * and, if it likes, its own actions and words for a row. The grid shows `SELECTION_ROW_PICKER`.
29
+ */
30
+ @customElement('ox-assistant-selection-bar')
31
+ export class OxAssistantSelectionBar extends LitElement {
32
+ static styles = [
33
+ ButtonStyles,
34
+ FormFieldStyles,
35
+ css`
36
+ :host { display:block; }
37
+ .selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }
38
+ .scope { font-size:var(--ui-t-body); font-weight:600; white-space:nowrap; }
39
+ form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }
40
+ input { flex:1; min-width:0; width:100%; }
41
+ .tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }
42
+ `
43
+ ]
44
+
45
+ /** The grid whose picked rows these are, asked for when needed. */
46
+ @property({ attribute: false }) gridOf?: () => SelectionGrid | undefined
47
+ /** The list as the person knows it (「계약 대장」). */
48
+ @property() name = ''
49
+ /** The list query the rows came from (`contracts`) - one the assistant reads. */
50
+ @property() list = ''
51
+ /** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */
52
+ @property() axis = ''
53
+ /** What the list itself does with the picked rows, before 「AI와 작업」. */
54
+ @property({ attribute: false }) actions: { title: string; action: () => any }[] = []
55
+ /** A row in the person's words, when the code and name do not say it (`selectionLabel`). */
56
+ @property({ attribute: false }) labelOf?: (row: any) => string
57
+
58
+ @state() count = 0
59
+ @state() request = ''
60
+
61
+ private get grid() {
62
+ return this.gridOf?.()
63
+ }
64
+
65
+ /* The grid's own events reach the page's root (bubbling, composed); the bar counts those that come from its grid. */
66
+ private root?: Node
67
+ private recount = async (event?: Event) => {
68
+ const grid = this.grid
69
+ if (event && (!grid || !event.composedPath().includes(grid))) return
70
+ await grid?.updateComplete
71
+ this.count = grid?.selected?.length ?? 0
72
+ }
73
+
74
+ connectedCallback() {
75
+ super.connectedCallback()
76
+ this.root = this.getRootNode()
77
+ for (const event of GRID_EVENTS) this.root.addEventListener(event, this.recount)
78
+ void this.recount()
79
+ }
80
+
81
+ disconnectedCallback() {
82
+ super.disconnectedCallback()
83
+ for (const event of GRID_EVENTS) this.root?.removeEventListener(event, this.recount)
84
+ this.root = undefined
85
+ }
86
+
87
+ willUpdate(changes: Map<string, unknown>) {
88
+ if (changes.has('gridOf')) void this.recount()
89
+ }
90
+
91
+ /*
92
+ * A screen request (design/ai/shared-dock.md §2-11-1, the user: 「"AI와 작업" 기능이 unstable 하다 … 새로운 세션을 여는지,
93
+ * 기존 세션을 활용하는지 등등을 명확하게 하라」): it goes to this screen's conversation with the rows picked now, in
94
+ * screen order, and is sent at once - what was written on the bar, or the set request. The bar is cleared only when it
95
+ * was sent; otherwise the dock has said why and the words stay to press again.
96
+ */
97
+ async ask(request: string = this.request) {
98
+ const rows = this.grid?.selected ?? []
99
+ if (!rows.length) {
100
+ notify({ message: i18next.t('ai-assistant.selection.pick-rows'), level: 'info' })
101
+ return
102
+ }
103
+ if (rows.some(row => !row.id || row.__dirty__)) {
104
+ notify({ message: i18next.t('ai-assistant.selection.save-first'), level: 'warn' })
105
+ return
106
+ }
107
+ const outcome = await askAssistant({
108
+ selection: { title: this.name, list: this.list, records: selectedRecords(rows, { axis: this.axis, labelOf: this.labelOf }) },
109
+ ask: request?.trim() || i18next.t('ai-assistant.selection.ask', { n: rows.length, title: this.name })
110
+ })
111
+ if (outcome?.sent && this.request === request) this.request = ''
112
+ }
113
+
114
+ render() {
115
+ return this.count
116
+ ? html`<div class="selection-work" role="region" aria-label=${i18next.t('ai-assistant.selection.region')}>
117
+ <span class="scope" role="status">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>
118
+ ${this.actions.length
119
+ ? html`<div class="tools">${this.actions.map(action => html`<button quiet small @click=${action.action}>${action.title}</button>`)}</div>`
120
+ : nothing}
121
+ <form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>
122
+ <input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
123
+ .value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />
124
+ <button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
125
+ </form>
126
+ </div>`
127
+ : nothing
128
+ }
129
+ }
@@ -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)
package/client/index.ts CHANGED
@@ -24,6 +24,8 @@ export * from './utils/assistant-proposals.js'
24
24
  export * from './components/workspace-view.js'
25
25
  export * from './utils/assistant-ask.js'
26
26
  export * from './utils/assistant-selection.js'
27
+ /* 「AI와 작업」 over a list - every application's lists hand their picked rows by it (shared-dock §2-11-1). */
28
+ export * from './components/assistant-selection-bar.js'
27
29
  export * from './utils/assistant-activity.js'
28
30
  export * from './utils/assistant-request-context.js'
29
31
  export * from './utils/assistant-capability.js'