@things-factory/ai-assistant 10.1.154 → 10.1.155

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 (121) hide show
  1. package/client/bootstrap.ts +27 -0
  2. package/client/components/assistant-chat.ts +229 -178
  3. package/client/components/assistant-dock.ts +53 -48
  4. package/client/components/assistant-session-manager.ts +82 -100
  5. package/client/components/assistant-session-toolbar.ts +11 -3
  6. package/client/components/chat-input-builder.ts +8 -2
  7. package/client/settings/agent-settings-model.ts +164 -0
  8. package/client/settings/ai-agent-setting-let.ts +684 -0
  9. package/client/settings/ai-usage-setting-let.ts +252 -0
  10. package/client/settings/usage-model.ts +46 -0
  11. package/client/setup-assistant-dock.ts +2 -3
  12. package/client/utils/agent-choice.ts +56 -0
  13. package/client/utils/assistant-navigation.test.ts +14 -8
  14. package/client/utils/assistant-navigation.ts +8 -3
  15. package/client/utils/composer-frame.ts +28 -2
  16. package/client/utils/menu-room.ts +33 -0
  17. package/dist-client/bootstrap.d.ts +8 -0
  18. package/dist-client/bootstrap.js +26 -0
  19. package/dist-client/bootstrap.js.map +1 -0
  20. package/dist-client/components/assistant-chat.d.ts +32 -4
  21. package/dist-client/components/assistant-chat.js +234 -175
  22. package/dist-client/components/assistant-chat.js.map +1 -1
  23. package/dist-client/components/assistant-dock.d.ts +6 -8
  24. package/dist-client/components/assistant-dock.js +52 -60
  25. package/dist-client/components/assistant-dock.js.map +1 -1
  26. package/dist-client/components/assistant-session-manager.d.ts +8 -7
  27. package/dist-client/components/assistant-session-manager.js +84 -124
  28. package/dist-client/components/assistant-session-manager.js.map +1 -1
  29. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  30. package/dist-client/components/assistant-session-toolbar.js +10 -3
  31. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +4 -0
  33. package/dist-client/components/chat-input-builder.js +4 -2
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/settings/agent-settings-model.d.ts +93 -0
  36. package/dist-client/settings/agent-settings-model.js +95 -0
  37. package/dist-client/settings/agent-settings-model.js.map +1 -0
  38. package/dist-client/settings/ai-agent-setting-let.d.ts +51 -0
  39. package/dist-client/settings/ai-agent-setting-let.js +672 -0
  40. package/dist-client/settings/ai-agent-setting-let.js.map +1 -0
  41. package/dist-client/settings/ai-usage-setting-let.d.ts +26 -0
  42. package/dist-client/settings/ai-usage-setting-let.js +276 -0
  43. package/dist-client/settings/ai-usage-setting-let.js.map +1 -0
  44. package/dist-client/settings/usage-model.d.ts +32 -0
  45. package/dist-client/settings/usage-model.js +25 -0
  46. package/dist-client/settings/usage-model.js.map +1 -0
  47. package/dist-client/setup-assistant-dock.d.ts +0 -1
  48. package/dist-client/setup-assistant-dock.js +0 -1
  49. package/dist-client/setup-assistant-dock.js.map +1 -1
  50. package/dist-client/tsconfig.tsbuildinfo +1 -1
  51. package/dist-client/utils/agent-choice.d.ts +24 -0
  52. package/dist-client/utils/agent-choice.js +38 -0
  53. package/dist-client/utils/agent-choice.js.map +1 -0
  54. package/dist-client/utils/assistant-navigation.d.ts +8 -3
  55. package/dist-client/utils/assistant-navigation.js +7 -2
  56. package/dist-client/utils/assistant-navigation.js.map +1 -1
  57. package/dist-client/utils/assistant-navigation.test.js +14 -8
  58. package/dist-client/utils/assistant-navigation.test.js.map +1 -1
  59. package/dist-client/utils/composer-frame.d.ts +23 -2
  60. package/dist-client/utils/composer-frame.js +20 -2
  61. package/dist-client/utils/composer-frame.js.map +1 -1
  62. package/dist-client/utils/menu-room.d.ts +13 -0
  63. package/dist-client/utils/menu-room.js +26 -0
  64. package/dist-client/utils/menu-room.js.map +1 -0
  65. package/dist-server/service/ai-agent-resolver.d.ts +82 -0
  66. package/dist-server/service/ai-agent-resolver.js +417 -0
  67. package/dist-server/service/ai-agent-resolver.js.map +1 -0
  68. package/dist-server/service/assistant-capability-resolver.d.ts +1 -1
  69. package/dist-server/service/assistant-capability-resolver.js +7 -7
  70. package/dist-server/service/assistant-capability-resolver.js.map +1 -1
  71. package/dist-server/service/assistant-chat-resolver.d.ts +2 -0
  72. package/dist-server/service/assistant-chat-resolver.js +32 -15
  73. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  74. package/dist-server/service/assistant-mode/resolve.d.ts +0 -1
  75. package/dist-server/service/assistant-mode/resolve.js +0 -1
  76. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  77. package/dist-server/service/assistant-mode/types.d.ts +1 -2
  78. package/dist-server/service/assistant-mode/types.js.map +1 -1
  79. package/dist-server/service/chat-session/chat-session.d.ts +2 -0
  80. package/dist-server/service/chat-session/chat-session.js +5 -0
  81. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  82. package/dist-server/service/index.d.ts +2 -1
  83. package/dist-server/service/index.js +3 -0
  84. package/dist-server/service/index.js.map +1 -1
  85. package/dist-server/service/testing/mode-conformance.js +0 -1
  86. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +9 -7
  89. package/server/service/ai-agent-resolver.ts +330 -0
  90. package/server/service/assistant-capability-resolver.ts +7 -7
  91. package/server/service/assistant-chat-resolver.ts +32 -24
  92. package/server/service/assistant-mode/resolve.test.ts +0 -2
  93. package/server/service/assistant-mode/resolve.ts +0 -2
  94. package/server/service/assistant-mode/types.ts +1 -2
  95. package/server/service/chat-session/chat-session.ts +5 -0
  96. package/server/service/index.ts +3 -0
  97. package/server/service/testing/mode-conformance.ts +0 -1
  98. package/server/tool-registry-boot-report.test.ts +2 -0
  99. package/test/ai-agent-settings.test.ts +123 -0
  100. package/test/assistant-dock.test.ts +3 -2
  101. package/test/assistant-session-manager.test.ts +10 -4
  102. package/test/assistant-session-toolbar.test.ts +8 -0
  103. package/test/composer-frame.test.ts +40 -2
  104. package/test/menu-room.test.ts +33 -0
  105. package/test/open-doors.test.ts +3 -1
  106. package/test/session-discovery.test.ts +7 -6
  107. package/things-factory.config.js +2 -0
  108. package/translations/en.json +78 -12
  109. package/translations/es.json +78 -12
  110. package/translations/ja.json +78 -12
  111. package/translations/ko.json +78 -12
  112. package/translations/ms.json +78 -12
  113. package/translations/zh.json +78 -12
  114. package/client/components/chat-triggers.test.ts +0 -72
  115. package/client/components/chat-triggers.ts +0 -62
  116. package/dist-client/components/chat-triggers.d.ts +0 -16
  117. package/dist-client/components/chat-triggers.js +0 -42
  118. package/dist-client/components/chat-triggers.js.map +0 -1
  119. package/dist-client/components/chat-triggers.test.d.ts +0 -1
  120. package/dist-client/components/chat-triggers.test.js +0 -61
  121. package/dist-client/components/chat-triggers.test.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { findMenu, foldForDock, unfoldAfterDock } from '../utils/menu-room.js'
1
2
  import '@material/web/icon/icon.js'
2
3
  import './assistant-chat.js'
3
4
  import './assistant-session-toolbar.js'
@@ -105,13 +106,17 @@ export class AssistantDock extends localize(i18next)(LitElement) {
105
106
  font-size: 13px;
106
107
  }
107
108
 
108
- /* Below 1440px the page keeps its full width and only the dock floats. The DOM stays - the chat does not reconnect. */
109
- @media (max-width: 1439.98px) {
109
+ /*
110
+ * The dock opens in one place on every screen and takes its room beside the page, so it covers no page's controls
111
+ * and no screen needs its own exception (2026-10-12). Only a screen too narrow to share floats it over the page.
112
+ * The DOM stays - the chat does not reconnect.
113
+ */
114
+ @media (max-width: 1023.98px) {
110
115
  :host {
111
116
  position: fixed;
112
- inset: var(--assistant-dock-overlay-top, 0px) 0 0 auto;
113
- height: calc(100vh - var(--assistant-dock-overlay-top, 0px));
114
- height: calc(100dvh - var(--assistant-dock-overlay-top, 0px));
117
+ inset: 0 0 0 auto;
118
+ height: 100vh;
119
+ height: 100dvh;
115
120
  z-index: 10;
116
121
  box-shadow: var(--md-sys-elevation-shadow-3, -4px 0 16px rgb(0 0 0 / 20%));
117
122
  }
@@ -278,8 +283,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
278
283
  .next-step a:hover { text-decoration: underline; }
279
284
  .next-step md-icon { --md-icon-size: 16px; }
280
285
  ox-assistant-chat[hidden] { display: none; }
281
- .transition { padding: 10px 12px; font-size: 13px; line-height: 1.5; border-bottom: 1px solid var(--md-sys-color-outline-variant); background: var(--md-sys-color-surface-container-low); }
282
- .transition button { display: block; margin-top: 6px; font: inherit; }
283
286
  `
284
287
  ]
285
288
 
@@ -292,7 +295,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
292
295
  @state() private anchor?: AnchorResult
293
296
  @state() private kind = ''
294
297
  /** kind → its label, for the manager's rows. */
295
- @state() private kindLabels: Record<string, string> = {}
296
298
  @state() private sessions: AssistantSession[] = []
297
299
  @state() private sessionId = ''
298
300
  @state() private loading = false
@@ -300,8 +302,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
300
302
  @state() private showingHistory = false
301
303
  @state() private conversationView = 0
302
304
  @state() private asking?: { ask?: string; draft?: string; requestId: string }
303
- @state() private screenChanged = false
304
- @state() private navigationNotice = ''
305
305
  private openedIdentity = ''
306
306
 
307
307
  private app = new AppController(this)
@@ -329,22 +329,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
329
329
 
330
330
  private onStartNewSession = () => void this.newConversation()
331
331
 
332
- /** An editor can reserve its toolbar above the floating dock. Other apps keep the full-height default. */
333
- @property({ attribute: false }) overlayBoundary?: () => Element | undefined
334
- private boundary?: Element
335
- private boundaryObserver?: ResizeObserver
336
- private placeOverlay = () => {
337
- const boundary = this.overlayBoundary?.()
338
- if (boundary !== this.boundary) {
339
- this.boundaryObserver?.disconnect()
340
- this.boundary = boundary
341
- if (boundary) {
342
- this.boundaryObserver = new ResizeObserver(this.placeOverlay)
343
- this.boundaryObserver.observe(boundary)
344
- }
345
- }
346
- this.style.setProperty('--assistant-dock-overlay-top', `${boundary?.getBoundingClientRect().bottom ?? 0}px`)
347
- }
348
332
 
349
333
  /* A card asks for a page of this application - the AI 작업판. The dock stays open beside it. */
350
334
  private onOpenPage = (event: CustomEvent) => {
@@ -364,10 +348,13 @@ export class AssistantDock extends localize(i18next)(LitElement) {
364
348
  else this.removeAttribute('audience')
365
349
  }
366
350
 
351
+ /* Whether the left menu was folded by this dock's opening - only then does closing open it again. */
352
+ private menuFoldedByDock = false
353
+
367
354
  connectedCallback() {
368
355
  super.connectedCallback()
369
- window.addEventListener('resize', this.placeOverlay)
370
- this.placeOverlay()
356
+ this.menuFoldedByDock = foldForDock(findMenu())
357
+ if (this.menuFoldedByDock) window.dispatchEvent(new Event('resize'))
371
358
  this.addEventListener('assistant-start-new-session', this.onStartNewSession)
372
359
  this.addEventListener('assistant-open-page', this.onOpenPage as EventListener)
373
360
  this.unsubscribers.push(
@@ -390,9 +377,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
390
377
 
391
378
  /* Closing keeps the conversation: the dock comes back as it was left (design/ai/shared-dock.md §2-1, §2-7). */
392
379
  disconnectedCallback() {
393
- window.removeEventListener('resize', this.placeOverlay)
394
- this.boundaryObserver?.disconnect()
395
- this.boundary = undefined
380
+ unfoldAfterDock(findMenu(), this.menuFoldedByDock)
381
+ if (this.menuFoldedByDock) window.dispatchEvent(new Event('resize'))
382
+ this.menuFoldedByDock = false
396
383
  this.removeEventListener('assistant-start-new-session', this.onStartNewSession)
397
384
  this.removeEventListener('assistant-open-page', this.onOpenPage as EventListener)
398
385
  this.unsubscribers.splice(0).forEach(unsubscribe => unsubscribe())
@@ -433,43 +420,66 @@ export class AssistantDock extends localize(i18next)(LitElement) {
433
420
  if (!(await this.mayLeaveConversation())) return
434
421
  this.chosenKind = ''
435
422
  this.surface = surface
436
- this.navigationNotice = surface ? t('now-about', 'Conversation subject: {name}', { name: surface.label() }) : ''
437
423
  await this.open()
438
424
  }
439
425
 
440
426
  /** The screen changed - a different surface or domain is a different conversation. */
441
427
  private follow(force = false) {
442
- this.placeOverlay()
443
428
  const next = currentAssistantSurface()
444
429
  const transition = surfaceTransition({
445
430
  identityChanged: this.openedIdentity !== this.identityKey(),
446
431
  sameSubject: this.scopeKey(next) === this.openedFor,
447
- active: isAnchor(this.anchor) && (!!this.sessionId || this.navigationState.draft || this.navigationState.busy)
432
+ busy: this.navigationState.busy
448
433
  })
449
434
  if (!force && transition === 'refresh') {
450
435
  this.surface = next
451
- this.screenChanged = false
452
436
  this.requestUpdate()
453
437
  return
454
438
  }
455
- if (!force && transition === 'offer') {
456
- this.screenChanged = true
439
+ if (!force && transition === 'wait') {
440
+ /* The answer lands in the conversation it was asked in; then the dock follows the page. */
441
+ this.followWhenIdle()
457
442
  return
458
443
  }
444
+ if (transition !== 'reset') this.keepDraft()
459
445
  if (transition === 'reset') {
460
446
  this.asking = undefined
461
447
  this.capability = 'unknown'
462
448
  if (this.openedIdentity) takeAssistantAsk()
463
449
  }
464
- this.navigationNotice = transition === 'follow' && next ? t('now-about', 'Conversation subject: {name}', { name: next.label() }) : ''
465
450
  if (next?.key !== this.surface?.key) this.chosenKind = ''
466
451
  this.surface = next
467
452
  void this.open()
468
453
  }
469
454
 
455
+ private followTimer?: ReturnType<typeof setTimeout>
456
+
457
+ private followWhenIdle() {
458
+ clearTimeout(this.followTimer)
459
+ this.followTimer = setTimeout(() => (this.navigationState.busy ? this.followWhenIdle() : this.follow()), 300)
460
+ }
461
+
462
+ /* What was being written, per subject - it goes back to its conversation when the page returns there. */
463
+ private drafts = new Map<string, unknown>()
464
+
465
+ private keepDraft() {
466
+ const chat = this.renderRoot.querySelector('ox-assistant-chat') as any
467
+ const draft = this.openedFor ? chat?.takeDraft?.() : undefined
468
+ if (draft) this.drafts.set(this.openedFor!, draft)
469
+ }
470
+
471
+ private async restoreDraft() {
472
+ const draft = this.openedFor ? this.drafts.get(this.openedFor) : undefined
473
+ if (!draft) return
474
+ await this.updateComplete
475
+ const chat = this.renderRoot.querySelector('ox-assistant-chat') as any
476
+ if (!chat?.restoreDraft) return
477
+ this.drafts.delete(this.openedFor!)
478
+ chat.restoreDraft(draft)
479
+ }
480
+
470
481
  private async open(): Promise<void> {
471
482
  this.openedIdentity = this.identityKey()
472
- this.screenChanged = false
473
483
  const ticket = ++this.revision
474
484
  this.conversationView++
475
485
  this.showingHistory = false
@@ -496,7 +506,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
496
506
 
497
507
  const modes = await modesFor(surface.subject)
498
508
  if (ticket !== this.revision) return
499
- this.kindLabels = Object.fromEntries(modes.map(mode => [mode.kind, mode.label || mode.kind]))
500
509
  const chosen = modes.find(mode => mode.kind === this.chosenKind) ?? modes.find(mode => mode.isDefault) ?? modes[0]
501
510
  this.kind = chosen?.kind ?? ''
502
511
  if (!this.kind) {
@@ -517,6 +526,8 @@ export class AssistantDock extends localize(i18next)(LitElement) {
517
526
  this.loading = false
518
527
  /* No conversation can stand here - an ask waiting for one is answered now, not sent later somewhere else. */
519
528
  if (this.anchor && !isAnchor(this.anchor)) void this.takeAsk()
529
+ /* What was being written on this subject before the page moved away comes back here. */
530
+ void this.restoreDraft()
520
531
  }
521
532
  }
522
533
  }
@@ -566,7 +577,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
566
577
 
567
578
  private async takeAsk() {
568
579
  if (this.loading || !this.anchor) return
569
- if (this.screenChanged || this.navigationState.busy || this.navigationState.draft) {
580
+ if (this.navigationState.busy || this.navigationState.draft) {
570
581
  const waiting = takeAssistantAsk()
571
582
  if (waiting) notify({ level: 'info', message: t('ask-kept-current', 'The current conversation was kept. Switch to the intended subject before asking again.') })
572
583
  return
@@ -818,18 +829,13 @@ export class AssistantDock extends localize(i18next)(LitElement) {
818
829
  <span>${surface.nextStep.description?.() || ''}</span>
819
830
  <a href=${surface.nextStep.href}>${surface.nextStep.label()}<md-icon>arrow_forward</md-icon></a>
820
831
  </div>` : nothing}
821
- ${this.screenChanged ? html`<div class="transition" role="status">
822
- ${t('screen-and-conversation', 'Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.', { screen: currentAssistantSurface()?.label() || t('no-subject', 'No subject'), conversation: surface?.label() || '' })}
823
- <button class="sbtn" @click=${() => this.requestSurface(currentAssistantSurface())}>${t('switch-to-screen', 'Talk about this screen')}</button>
824
- </div>` : this.navigationNotice ? html`<div class="transition" role="status">${this.navigationNotice}</div>` : nothing}
832
+
825
833
  ${ready && this.showingHistory
826
834
  ? html`<ox-assistant-session-manager
827
835
  .anchorType=${(anchor as any).anchorType}
828
836
  .anchorId=${(anchor as any).anchorId}
829
837
  .sessionId=${this.sessionId}
830
838
  .kind=${this.kind}
831
- .kindLabels=${this.kindLabels}
832
- .scopeLabel=${t('conversation-scope', 'Conversations · {name}', { name: surface?.label() || '' })}
833
839
  @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId, event.detail.kind)}
834
840
  @assistant-session-reset=${() => this.newConversation()}
835
841
  @assistant-session-changed=${() => this.reloadSessions()}
@@ -860,7 +866,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
860
866
  this.conversationView,
861
867
  html`<ox-assistant-chat
862
868
  ?hidden=${this.showingHistory}
863
- .sendBlocked=${this.screenChanged ? t('return-to-subject', 'Return to the conversation subject before sending or applying changes.') : ''}
864
869
  .sessionId=${this.sessionId}
865
870
  .prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
866
871
  .contextProvider=${() => {
@@ -5,6 +5,8 @@ import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next } from '@operato/i18n'
7
7
  import { OxPrompt } from '@operato/popup'
8
+ import '@operato/popup/ox-popup-menu.js'
9
+ import { OxPopupMenu } from '@operato/popup/ox-popup-menu.js'
8
10
 
9
11
  import {
10
12
  assistantSessionAdmin,
@@ -47,29 +49,25 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
47
49
  @customElement('ox-assistant-session-manager')
48
50
  export class AssistantSessionManager extends LitElement {
49
51
  static styles = css`
50
- .scope-label { padding: 12px 12px 0; font-size: 14px; font-weight: 600; }
51
- .filter-toggle, .reset { margin: 6px 10px; padding: 7px; font: inherit; background: transparent; color: inherit; border: 1px solid var(--md-sys-color-outline-variant); border-radius: 6px; cursor: pointer; }
52
- .filter-toggle { align-self: flex-end; }
53
- .filters[hidden] { display: none; }
54
- .open-row { display: block; flex: 1; min-width: 0; text-align: start; border: 0; background: transparent; color: inherit; font: inherit; padding: 4px; cursor: pointer; }
55
- .meta { display: block; margin-top: 5px; font-size: 12px; color: var(--md-sys-color-on-surface-variant); }
52
+ /* One scale for the list: name 13, preview 12, meta 11 - the rest of the dock's sizes. */
53
+ .open-row { display: block; flex: 1; min-width: 0; text-align: start; border: 0; background: transparent; color: inherit; font: inherit; padding: 0; cursor: pointer; }
54
+ .meta { display: block; margin-top: 2px; font-size: 11px; color: var(--md-sys-color-on-surface-variant); }
56
55
  /* Open and private must part at a glance - an open row is read by everyone in the domain (2026-09-30). */
57
56
  .audience { display: inline-flex; align-items: center; gap: 3px; vertical-align: middle; }
58
57
  .audience md-icon { --md-icon-size: 14px; }
59
58
  /* The dock's warning colour, fixed across applications - see assistant-dock.ts. */
60
59
  :host { --assistant-open-edge: #c47f00; --assistant-open-surface: #f2b01e; --assistant-open-ink: #2e2100; }
61
- .audience.open { padding: 1px 6px 1px 4px; border-radius: 6px; background: var(--assistant-open-surface); color: var(--assistant-open-ink); font-weight: 600; }
60
+ .audience.open { padding: 0 5px 0 3px; border-radius: 4px; background: var(--assistant-open-surface); color: var(--assistant-open-ink); font-weight: 600; }
61
+ .audience md-icon { --md-icon-size: 12px; }
62
62
  .row.open-audience { box-shadow: inset 4px 0 0 var(--assistant-open-edge); }
63
- .actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
64
- .actions[open] .acts { display: flex; flex-direction: column; position: absolute; right: 10px; top: 40px; z-index: 2; min-width: 180px; padding: 6px; background: var(--md-sys-color-surface-container-lowest); border: 1px solid var(--md-sys-color-outline-variant); border-radius: 8px; box-shadow: 0 4px 16px #0002; }
65
- .actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
66
- .actions button.danger { color: var(--md-sys-color-error, #b42318); }
67
- button:focus-visible, summary:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
63
+ .more-menu { flex: none; border: 0; border-radius: 4px; padding: 2px 6px; background: none; color: var(--md-sys-color-on-surface-variant); font-size: 14px; line-height: 1; cursor: pointer; }
64
+ .more-menu:hover { background: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface); }
65
+ button:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
68
66
  :host {
69
67
  display: flex;
70
68
  flex-direction: column;
71
69
  min-height: 0;
72
- font-size: 14px;
70
+ font-size: 13px;
73
71
  color: var(--md-sys-color-on-surface);
74
72
  }
75
73
 
@@ -77,14 +75,14 @@ export class AssistantSessionManager extends LitElement {
77
75
  display: flex;
78
76
  align-items: center;
79
77
  gap: 6px;
80
- padding: 8px 10px;
81
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
78
+ padding: 8px 10px 6px;
82
79
  }
83
80
 
84
81
  .bar input,
85
82
  .bar select {
86
83
  min-width: 0;
87
- height: 28px;
84
+ height: 26px;
85
+ font-size: 12px;
88
86
  box-sizing: border-box;
89
87
  border: 1px solid var(--md-sys-color-outline-variant);
90
88
  border-radius: 6px;
@@ -106,28 +104,32 @@ export class AssistantSessionManager extends LitElement {
106
104
  background: transparent;
107
105
  color: var(--md-sys-color-primary);
108
106
  font: inherit;
109
- font-weight: 650;
107
+ font-size: 12px;
108
+ font-weight: 600;
109
+ white-space: nowrap;
110
110
  cursor: pointer;
111
111
  }
112
112
 
113
+ /* Always open, one quiet line under the search - the same small size as the search. */
113
114
  .filters {
114
115
  display: flex;
115
116
  flex-wrap: wrap;
116
117
  align-items: center;
117
- gap: 6px 10px;
118
- padding: 6px 10px;
118
+ gap: 4px 10px;
119
+ padding: 0 10px 8px;
119
120
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
120
- font-size: 11.5px;
121
+ font-size: 11px;
121
122
  color: var(--md-sys-color-on-surface-variant);
122
123
  }
123
124
 
124
125
  .filters select {
125
- height: 26px;
126
+ height: 22px;
126
127
  border: 1px solid var(--md-sys-color-outline-variant);
127
- border-radius: 6px;
128
+ border-radius: 4px;
128
129
  background: var(--md-sys-color-surface-container-lowest);
129
130
  color: inherit;
130
131
  font: inherit;
132
+ padding: 0 4px;
131
133
  }
132
134
 
133
135
  .filters label {
@@ -137,8 +139,18 @@ export class AssistantSessionManager extends LitElement {
137
139
  cursor: pointer;
138
140
  }
139
141
 
140
- .kind {
142
+ .filters input[type='checkbox'] {
143
+ margin: 0;
144
+ }
145
+
146
+ .filters .clear {
147
+ margin-left: auto;
148
+ border: 0;
149
+ background: none;
150
+ padding: 0;
141
151
  color: var(--md-sys-color-primary);
152
+ font: inherit;
153
+ cursor: pointer;
142
154
  }
143
155
 
144
156
  .list {
@@ -152,7 +164,7 @@ export class AssistantSessionManager extends LitElement {
152
164
  display: flex;
153
165
  align-items: center;
154
166
  gap: 8px;
155
- padding: 9px 10px;
167
+ padding: 7px 10px;
156
168
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
157
169
  cursor: pointer;
158
170
  }
@@ -200,33 +212,9 @@ export class AssistantSessionManager extends LitElement {
200
212
  }
201
213
 
202
214
  .prev {
215
+ margin-top: 1px;
203
216
  color: var(--md-sys-color-on-surface-variant);
204
- font-size: 11px;
205
- }
206
-
207
- /* Row actions show on hover and on keyboard focus - the list's body is the names. */
208
- .acts {
209
- display: none;
210
- gap: 2px;
211
- }
212
-
213
- button.icon {
214
- display: inline-flex;
215
- padding: 3px;
216
- border: 0;
217
- border-radius: 6px;
218
- background: none;
219
- color: var(--md-sys-color-on-surface-variant);
220
- cursor: pointer;
221
- }
222
-
223
- button.icon:hover {
224
- background: var(--md-sys-color-surface-container-high);
225
- color: var(--md-sys-color-on-surface);
226
- }
227
-
228
- button.icon.danger:hover {
229
- color: var(--md-sys-color-error);
217
+ font-size: 12px;
230
218
  }
231
219
 
232
220
  .rename {
@@ -313,19 +301,15 @@ export class AssistantSessionManager extends LitElement {
313
301
  @property({ type: String }) anchorId = ''
314
302
  /** The conversation the dock has open - marked in the list. */
315
303
  @property({ type: String }) sessionId = ''
316
- @property({ type: String }) scopeLabel = ''
317
304
  /** The doors. Replaceable for tests; a host normally leaves it. */
318
305
  @property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
319
306
  /** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
320
307
  @property({ type: String }) kind = ''
321
- /** kind → the words people know it by (the modes' labels), for each row. */
322
- @property({ attribute: false }) kindLabels: Record<string, string> = {}
323
308
 
324
309
  @state() private entries: AssistantSessionEntry[] = []
325
310
  @state() private total = 0
326
311
  @state() private hiddenCount = 0
327
312
  @state() private search = ''
328
- @state() private allKinds = false
329
313
  @state() private whose: NonNullable<AssistantSessionFilters['whose']> = 'all'
330
314
  @state() private visibility: NonNullable<AssistantSessionFilters['visibility']> = 'all'
331
315
  @state() private includeHidden = false
@@ -334,17 +318,20 @@ export class AssistantSessionManager extends LitElement {
334
318
  @state() private loading = false
335
319
  @state() private error = ''
336
320
  @state() private renamingId = ''
337
- @state() private filtersOpen = false
338
321
 
339
- private get hasFilters() {
340
- return !!this.search.trim() || this.allKinds || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
322
+ /*
323
+ * The search and the filters stand open together, always (2026-10-12) - a filter behind a "Filters" button is one
324
+ * more thing to find before finding the conversation. The list is this screen's conversations, so there is no kind
325
+ * to choose.
326
+ */
327
+ private get filtering() {
328
+ return !!this.search.trim() || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
341
329
  }
342
330
 
343
- private resetFilters() {
331
+ private clearFilters() {
344
332
  clearTimeout(this.searchTimer)
345
333
  this.refilter(() => {
346
334
  this.search = ''
347
- this.allKinds = false
348
335
  this.whose = 'all'
349
336
  this.visibility = 'all'
350
337
  this.includeHidden = this.unreadOnly = false
@@ -375,7 +362,7 @@ export class AssistantSessionManager extends LitElement {
375
362
  anchorId: this.anchorId,
376
363
  search: this.search,
377
364
  filters: {
378
- kind: this.allKinds ? '' : this.kind,
365
+ kind: this.kind,
379
366
  whose: this.whose,
380
367
  visibility: this.visibility,
381
368
  includeHidden: this.includeHidden,
@@ -491,7 +478,6 @@ export class AssistantSessionManager extends LitElement {
491
478
  const shown = this.entries.length
492
479
 
493
480
  return html`
494
- <div class="scope-label">${this.scopeLabel}</div>
495
481
  <div class="bar">
496
482
  <input
497
483
  .value=${this.search}
@@ -502,15 +488,7 @@ export class AssistantSessionManager extends LitElement {
502
488
  />
503
489
  <button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
504
490
  </div>
505
- <button class="filter-toggle" aria-expanded=${this.filtersOpen} @click=${() => (this.filtersOpen = !this.filtersOpen)}>${t('filters', 'Filters')}${this.hasFilters ? ' •' : ''}</button>
506
- <div class="filters" ?hidden=${!this.filtersOpen}>
507
- ${this.kind
508
- ? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
509
- @change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
510
- <option value="this">${this.kindLabels[this.kind] || t('filter-this-kind', 'This conversation')}</option>
511
- <option value="all">${t('filter-all-kinds', 'All kinds here')}</option>
512
- </select>`
513
- : nothing}
491
+ <div class="filters">
514
492
  <select aria-label=${t('filter-whose', 'Whose')} .value=${this.whose}
515
493
  @change=${(e: Event) => this.refilter(() => (this.whose = (e.target as HTMLSelectElement).value as any))}>
516
494
  <option value="all">${t('filter-all', 'All')}</option>
@@ -527,13 +505,13 @@ export class AssistantSessionManager extends LitElement {
527
505
  @change=${(e: Event) => this.refilter(() => (this.unreadOnly = (e.target as HTMLInputElement).checked))} />${t('filter-unread', 'Unread')}</label>
528
506
  <label><input type="checkbox" .checked=${this.includeHidden}
529
507
  @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
508
+ ${this.filtering ? html`<button class="clear" @click=${() => this.clearFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
530
509
  </div>
531
- ${this.hasFilters ? html`<button class="reset" @click=${() => this.resetFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
532
510
  <div class="list">
533
511
  ${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
534
512
  ${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
535
513
  ${shown === 0 && !this.loading && !this.error
536
- ? html`<div class="empty" role="status">${this.hasFilters ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations for this subject yet.')}</div>`
514
+ ? html`<div class="empty" role="status">${this.filtering ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations yet.')}</div>`
537
515
  : this.entries.map(entry => this.renderRow(entry))}
538
516
  ${shown < this.total
539
517
  ? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
@@ -575,39 +553,43 @@ export class AssistantSessionManager extends LitElement {
575
553
  <span class="dot"></span>
576
554
  <button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
577
555
  <div class="title">${name}</div>
578
- <div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
556
+ <div class="prev">${entry.lastMessagePreview || ''}</div>
579
557
  <span class="meta"><span class="audience ${isPrivate ? 'private' : 'open'}"><md-icon aria-hidden="true">${isPrivate ? 'lock' : 'public'}</md-icon>${isPrivate ? t('audience-private', 'Participants only') : t('audience-open', 'Everyone in this domain')}</span>${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>
580
558
  </button>
581
- <details class="actions" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); const details = e.currentTarget as HTMLDetailsElement; details.open = false; details.querySelector('summary')?.focus() } }}>
582
- <summary aria-label=${t('manage', 'Manage conversation')}>⋯</summary>
583
- <span class="acts" @click=${(e: Event) => e.stopPropagation()}>
584
- ${actions.rename
585
- ? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
586
- ${t('rename', 'Rename')}
587
- </button>`
588
- : nothing}
559
+ <button
560
+ class="more-menu"
561
+ aria-label=${t('manage', 'Manage conversation')}
562
+ title=${t('manage', 'Manage conversation')}
563
+ @click=${(e: MouseEvent) => this.openRowMenu(e, entry, name)}>⋯</button>
564
+ </div>`
565
+ }
566
+
567
+ /**
568
+ * A row's actions in the platform's popup menu - one open at a time, closed by a press outside or Escape, placed
569
+ * inside the window. The hand-made menu it replaces stayed open and stacked over the next rows.
570
+ */
571
+ private openRowMenu(event: MouseEvent, entry: AssistantSessionEntry, name: string) {
572
+ event.stopPropagation()
573
+ const actions = sessionActions(entry)
574
+ const isPrivate = entry.visibility === 'private'
575
+ const at = (event.currentTarget as HTMLElement).getBoundingClientRect()
576
+ OxPopupMenu.open({
577
+ template: html`
578
+ ${actions.rename ? html`<ox-popup-menuitem label=${t('rename', 'Rename')} @select=${() => (this.renamingId = entry.id)}></ox-popup-menuitem>` : nothing}
589
579
  ${actions.setVisibility
590
580
  ? isPrivate
591
- ? html`<button class="icon" title=${t('make-open', 'Make public')} aria-label=${t('make-open', 'Make public')}
592
- @click=${() => this.askOpen(entry, name)}>${t('make-open', 'Make public')}</button>`
593
- : html`<button class="icon" title=${t('make-private', 'Make private')} aria-label=${t('make-private', 'Make private')}
594
- @click=${() => this.setVisibility(entry, 'private')}>${t('make-private', 'Make private')}</button>`
595
- : nothing}
596
- <button
597
- class="icon"
598
- title=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
599
- aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
600
- @click=${() => this.setHidden(entry, !entry.hidden)}
601
- >
602
- ${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
603
- </button>
604
- ${actions.remove
605
- ? html`<button class="icon danger" title=${t('remove', 'Delete')} aria-label=${t('remove', 'Delete')}
606
- @click=${() => this.askRemove(entry, name)}>${t('remove', 'Delete')}</button>`
581
+ ? html`<ox-popup-menuitem label=${t('make-open', 'Make public')} @select=${() => this.askOpen(entry, name)}></ox-popup-menuitem>`
582
+ : html`<ox-popup-menuitem label=${t('make-private', 'Make private')} @select=${() => this.setVisibility(entry, 'private')}></ox-popup-menuitem>`
607
583
  : nothing}
608
- </span>
609
- </details>
610
- </div>`
584
+ <ox-popup-menuitem
585
+ label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
586
+ @select=${() => this.setHidden(entry, !entry.hidden)}></ox-popup-menuitem>
587
+ ${actions.remove ? html`<ox-popup-menuitem label=${t('remove', 'Delete')} @select=${() => this.askRemove(entry, name)}></ox-popup-menuitem>` : nothing}
588
+ `,
589
+ top: at.bottom,
590
+ right: window.innerWidth - at.right,
591
+ parent: document.body
592
+ })
611
593
  }
612
594
 
613
595
  /** Before what cannot be taken back - the platform's confirm, [Cancel][OK] (decided 2026-09-25, §7-3). */
@@ -31,6 +31,9 @@ export class AssistantSessionToolbar extends LitElement {
31
31
  [role=tab]:hover { background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); color: var(--primary-color, #007f87); }
32
32
  [aria-selected=true] { color:var(--primary-color, #007f87); border-bottom-color: currentColor; font-weight:600; }
33
33
  .session-new { flex: none; padding: 0 7px; font-weight: 500; white-space: nowrap; }
34
+ /* The open conversation's name is one line - a long name is cut, and the whole of it is the tooltip. */
35
+ .list { flex: none; white-space: nowrap; }
36
+ .current { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
34
37
  .session-new:hover, .history:hover { color: var(--primary-color, #007f87); background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); }
35
38
  .history { height: 24px; margin: 1px 0 0 4px; padding: 0 5px; font-size: 11px; color: var(--tertiary-color, #88939a); }
36
39
  :host([compact]) .controls { gap: 3px; min-height: 24px; }
@@ -47,20 +50,25 @@ export class AssistantSessionToolbar extends LitElement {
47
50
  this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
48
51
  }
49
52
 
53
+ private get currentName(): string {
54
+ return this.sessions.find(session => session.id === this.sessionId)?.name || i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })
55
+ }
56
+
50
57
  render() {
51
58
  const label = i18next.t('ai-assistant.session.history', { defaultValue: 'Conversation history' })
52
59
  return html`
53
60
  <div class="controls">
54
- ${this.singleConversation ? html`<button @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.list', { defaultValue: 'Conversations' })}</button><span class="tabs">${this.sessions.find(session => session.id === this.sessionId)?.name || i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</span>` : nothing}
61
+ ${this.singleConversation ? html`<button class="list" @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.list', { defaultValue: 'Conversations' })}</button><span class="current" title=${this.currentName}>${this.currentName}</span>` : nothing}
55
62
  ${this.temporary || this.singleConversation ? nothing : html`<div class="tabs" role="tablist" aria-label=${label}>
56
63
  ${!this.sessionId ? html`<button role="tab" aria-selected="true">${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>` : nothing}
57
64
  ${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html`<button role="tab" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}
58
65
  ?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}
59
66
  @click=${() => this.emit('assistant-session-select', { sessionId: session.id })}>${session.name || `${label} ${index + 1}`}</button>`)}
60
67
  </div>`}
61
- <button class="session-new" ?disabled=${this.disabled || this.loading || !!this.error}
68
+ <!-- A conversation not yet started is already the new one - offering another names it twice. -->
69
+ ${this.sessionId ? html`<button class="session-new" ?disabled=${this.disabled || this.loading || !!this.error}
62
70
  title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: 'Keep previous history and your work, and start a new conversation. Previous messages are excluded from subsequent requests.' })}
63
- @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>
71
+ @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>` : nothing}
64
72
  </div>
65
73
  ${this.historyEnabled && !this.singleConversation ? html`<button class="history" ?disabled=${this.loading || this.disabled}
66
74
  @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: 'All History' })}</button>` : nothing}
@@ -46,6 +46,8 @@ export interface ChatMutationInputArgs {
46
46
  * ones above. Omitted for a surface that declares no mode.
47
47
  */
48
48
  mode?: { kind: string; target?: string }
49
+ /** The AI agent the person picked. Omitted, the domain default answers. */
50
+ agentId?: string
49
51
  }
50
52
 
51
53
  /**
@@ -71,7 +73,8 @@ export function buildChatMutationInput(args: ChatMutationInputArgs): Record<stri
71
73
  componentSchemas: args.componentSchemas,
72
74
  selectedRefids: args.selectedRefids,
73
75
  ...(args.mentions && args.mentions.length > 0 && { mentions: args.mentions }),
74
- ...(args.mode?.kind ? { mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) } } : {})
76
+ ...(args.mode?.kind ? { mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) } } : {}),
77
+ ...(args.agentId ? { agentId: args.agentId } : {})
75
78
  }
76
79
  }
77
80
 
@@ -96,6 +99,8 @@ export interface AssistantChatInputArgs {
96
99
  hostContext?: any
97
100
  /** Files stored with the conversation by attachToConversation, sent with this turn. */
98
101
  attachmentIds?: string[]
102
+ /** The AI agent the person picked. Omitted, the domain default answers. */
103
+ agentId?: string
99
104
  }
100
105
 
101
106
  /**
@@ -119,6 +124,7 @@ export function buildAssistantChatInput(args: AssistantChatInputArgs): Record<st
119
124
  mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) },
120
125
  ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),
121
126
  ...(args.hostContext ? { hostContext: args.hostContext } : {}),
122
- ...(args.attachmentIds?.length ? { attachmentIds: args.attachmentIds } : {})
127
+ ...(args.attachmentIds?.length ? { attachmentIds: args.attachmentIds } : {}),
128
+ ...(args.agentId ? { agentId: args.agentId } : {})
123
129
  }
124
130
  }