@things-factory/ai-assistant 10.1.154 → 10.1.156

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
@@ -0,0 +1,27 @@
1
+ import { html } from 'lit'
2
+ import { store } from '@operato/shell'
3
+ import { auth, hasPrivilege } from '@things-factory/auth-base/dist-client/index.js'
4
+ import { ADD_SETTING } from '@things-factory/setting-base/dist-client/index.js'
5
+
6
+ import './settings/ai-agent-setting-let.js'
7
+ import './settings/ai-usage-setting-let.js'
8
+
9
+ /**
10
+ * Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›
11
+ * {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the
12
+ * tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.
13
+ */
14
+ export default function bootstrap() {
15
+ auth.on('profile', async () => {
16
+ if (!(await hasPrivilege({ privilege: 'mutation', category: 'setting', domainOwnerGranted: true }))) return
17
+
18
+ store.dispatch({
19
+ type: ADD_SETTING,
20
+ setting: { seq: 60, scope: 'tenant', template: html`<ai-agent-setting-let></ai-agent-setting-let>` }
21
+ })
22
+ store.dispatch({
23
+ type: ADD_SETTING,
24
+ setting: { seq: 61, scope: 'tenant', template: html`<ai-usage-setting-let></ai-usage-setting-let>` }
25
+ })
26
+ })
27
+ }
@@ -36,10 +36,12 @@ import {
36
36
  } from './markdown.js'
37
37
  import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
38
38
  import { pastedTableFile } from '../utils/pasted-table.js'
39
- import { appendWords, composerHeight, composerKeyAction, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
39
+ import { appendWords, composerHeight, composerKeyAction, composerSuggestion, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
40
40
  import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
41
41
  import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
42
42
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
43
+ import { agentDetail, offersAgentPicker, pickAgentId, rememberAgent, rememberedAgent, type AgentChoice } from '../utils/agent-choice.js'
44
+ import { store } from '@operato/shell'
43
45
  import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
44
46
  import './mention-popup.js'
45
47
  import {
@@ -68,7 +70,6 @@ import {
68
70
  import { chatDefaults } from './chat-defaults.js'
69
71
  import { isDifferentConversation, resolveConversation, type AssistantMode, type AssistantModeView, pickInitialMode } from './assistant-mode.js'
70
72
  import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState, type ParsedProposalOutcome } from './proposal-outcome-line.js'
71
- import { availableTriggers, type TriggerChip } from './chat-triggers.js'
72
73
  import './entry-review.js'
73
74
  import { entryGroupOf, entryReadyCount, entryTitle, isChangeProposal, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
74
75
  import { isWorkspaceProposal, versionOfCard, workspaceAddress } from '../utils/workspace.js'
@@ -235,6 +236,18 @@ const ASSISTANT_MODES_QUERY = gql`
235
236
  }
236
237
  `
237
238
 
239
+ const AI_AGENT_CHOICES_QUERY = gql`
240
+ query AiAgentChoices {
241
+ aiAgentChoices {
242
+ id
243
+ name
244
+ provider
245
+ model
246
+ isDefault
247
+ }
248
+ }
249
+ `
250
+
238
251
  const ASSISTANT_MODE_QUERY = gql`
239
252
  query AssistantMode($kind: String!) {
240
253
  assistantMode(kind: $kind) {
@@ -916,6 +929,23 @@ export class OxAssistantChat extends LitElement {
916
929
  return { busy: this.busy, draft: !!this.input.trim() || this.attachments.length > 0 }
917
930
  }
918
931
 
932
+ /**
933
+ * What is being written, handed to the host when the page moves to another subject - it belongs to the conversation
934
+ * it was written in, and comes back with `restoreDraft` when the person returns. The box is left empty.
935
+ */
936
+ takeDraft(): { text: string; attachments: ChatAttachment[] } | undefined {
937
+ if (!this.input.trim() && !this.attachments.length) return undefined
938
+ const draft = { text: this.input, attachments: this.attachments }
939
+ this.input = ''
940
+ this.attachments = []
941
+ return draft
942
+ }
943
+
944
+ restoreDraft(draft: { text: string; attachments: ChatAttachment[] }) {
945
+ this.input = draft.text
946
+ this.attachments = draft.attachments
947
+ }
948
+
919
949
  @state()
920
950
  private errorMessage?: string
921
951
 
@@ -1028,6 +1058,40 @@ export class OxAssistantChat extends LitElement {
1028
1058
 
1029
1059
  private modeChoicesToken = 0
1030
1060
 
1061
+ /** The AI agents this domain lets a person pick (design/ai/agent-settings.md §5). One or none draws no picker. */
1062
+ @state() private agentChoices: AgentChoice[] = []
1063
+ @state() private agentId?: string
1064
+
1065
+ private agentChoicesToken = 0
1066
+
1067
+ private get contextPath(): string | undefined {
1068
+ return (store.getState() as any)?.app?.contextPath
1069
+ }
1070
+
1071
+ /**
1072
+ * Asks which agents this domain offers. The list changes when someone edits the domain's agents, so it is read
1073
+ * again whenever the conversation's subject is - never cached for the life of the page.
1074
+ */
1075
+ private async loadAgentChoices() {
1076
+ const token = ++this.agentChoicesToken
1077
+ try {
1078
+ const result = await client.query({ query: AI_AGENT_CHOICES_QUERY, fetchPolicy: 'network-only' })
1079
+ if (token !== this.agentChoicesToken) return
1080
+ this.agentChoices = result.data?.aiAgentChoices ?? []
1081
+ } catch {
1082
+ /* Not knowing the list leaves the domain default to answer - the picker just does not appear. */
1083
+ if (token !== this.agentChoicesToken) return
1084
+ this.agentChoices = []
1085
+ }
1086
+ this.agentId = pickAgentId(this.agentChoices, rememberedAgent(this.contextPath))
1087
+ }
1088
+
1089
+ private onAgentPick(agentId: string) {
1090
+ if (!this.agentChoices.some(one => one.id === agentId)) return
1091
+ this.agentId = agentId
1092
+ rememberAgent(this.contextPath, agentId)
1093
+ }
1094
+
1031
1095
  /**
1032
1096
  * 이 화면에 설 수 있는 대화를 서버에 묻는다.
1033
1097
  *
@@ -1211,6 +1275,12 @@ export class OxAssistantChat extends LitElement {
1211
1275
  padding: 2px 10px;
1212
1276
  white-space: nowrap;
1213
1277
  }
1278
+ /* The agent sits at the end of the row above the box. */
1279
+ .agent-select {
1280
+ margin-left: auto;
1281
+ max-width: 50%;
1282
+ text-overflow: ellipsis;
1283
+ }
1214
1284
  .mode-switch:hover:not([disabled]) {
1215
1285
  background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent);
1216
1286
  color: var(--primary-color, #007f87);
@@ -1447,77 +1517,6 @@ export class OxAssistantChat extends LitElement {
1447
1517
  line-height: 1.6;
1448
1518
  text-align: center;
1449
1519
  }
1450
- .empty .footer .badge {
1451
- display: inline-block;
1452
- margin: 0 4px;
1453
- padding: 1px 7px;
1454
- background: var(--md-sys-color-surface-container, #f8fafc);
1455
- border-radius: 8px;
1456
- font-size: 9.5px;
1457
- color: var(--md-sys-color-on-surface-variant, #64748b);
1458
- }
1459
- /* ── Trigger chip row (empty state) ──────────────── */
1460
- .empty .trigger-row {
1461
- margin: 12px 0 4px;
1462
- padding: 10px 12px;
1463
- background: var(--md-sys-color-surface-container-low, #f8fafc);
1464
- border: 1px solid var(--md-sys-color-outline-variant, #eef2f6);
1465
- border-radius: 10px;
1466
- }
1467
- .empty .trigger-row-label {
1468
- font-size: 10px;
1469
- color: var(--md-sys-color-on-surface-variant, #64748b);
1470
- margin-bottom: 6px;
1471
- text-align: center;
1472
- letter-spacing: 0.02em;
1473
- }
1474
- .empty .trigger-chips {
1475
- display: flex;
1476
- gap: 6px;
1477
- justify-content: center;
1478
- flex-wrap: wrap;
1479
- }
1480
- .empty .trigger-chip {
1481
- display: inline-flex;
1482
- align-items: center;
1483
- gap: 5px;
1484
- padding: 5px 10px;
1485
- background: var(--md-sys-color-surface, #ffffff);
1486
- border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1487
- border-radius: 18px;
1488
- cursor: pointer;
1489
- font-size: 11.5px;
1490
- color: var(--md-sys-color-on-surface, #0f172a);
1491
- transition:
1492
- border-color 0.15s,
1493
- background 0.15s,
1494
- color 0.15s;
1495
- }
1496
- .empty .trigger-chip:hover {
1497
- border-color: var(--md-sys-color-primary, #2563eb);
1498
- background: var(--md-sys-color-primary-container, #dbeafe);
1499
- color: var(--md-sys-color-on-primary-container, #1e3a8a);
1500
- }
1501
- .empty .trigger-chip .trigger-char {
1502
- font-family:
1503
- 'SF Mono',
1504
- Menlo,
1505
- Monaco,
1506
- Consolas,
1507
- monospace;
1508
- font-size: 13px;
1509
- font-weight: 600;
1510
- color: var(--md-sys-color-primary, #2563eb);
1511
- min-width: 12px;
1512
- text-align: center;
1513
- }
1514
- .empty .trigger-chip:hover .trigger-char {
1515
- color: var(--md-sys-color-on-primary-container, #1e3a8a);
1516
- }
1517
- .empty .trigger-chip .trigger-desc {
1518
- font-weight: 500;
1519
- }
1520
-
1521
1520
  /* ── Message label (sender hint) ──────────────────── */
1522
1521
  .msg-label {
1523
1522
  font-size: 9.5px;
@@ -2435,9 +2434,6 @@ export class OxAssistantChat extends LitElement {
2435
2434
  .composer.wide textarea {
2436
2435
  min-height: 0;
2437
2436
  }
2438
- .composer-size {
2439
- margin-left: auto;
2440
- }
2441
2437
  /* Named above the send button, out of the row - it takes no width from the box. */
2442
2438
  .composer.wide .input-row {
2443
2439
  position: relative;
@@ -2445,7 +2441,7 @@ export class OxAssistantChat extends LitElement {
2445
2441
  .send-shortcut {
2446
2442
  position: absolute;
2447
2443
  right: 14px;
2448
- bottom: 48px;
2444
+ bottom: 68px;
2449
2445
  font-size: 10px;
2450
2446
  color: var(--md-sys-color-outline, #64748b);
2451
2447
  white-space: nowrap;
@@ -2724,6 +2720,34 @@ export class OxAssistantChat extends LitElement {
2724
2720
  opacity: 0.7;
2725
2721
  }
2726
2722
 
2723
+ /* Low-key words in the row above the box - examples, write wide. Text, no frame (page-standard.md §8 quiet). */
2724
+ .quiet {
2725
+ all: unset;
2726
+ cursor: pointer;
2727
+ font-size: 11px;
2728
+ line-height: 20px;
2729
+ color: var(--md-sys-color-on-surface-variant, #475569);
2730
+ }
2731
+ .quiet:hover,
2732
+ .quiet[aria-expanded='true'],
2733
+ .quiet[aria-pressed='true'] {
2734
+ color: var(--md-sys-color-primary, #0f172a);
2735
+ text-decoration: underline;
2736
+ text-underline-offset: 2px;
2737
+ }
2738
+ /* The words in the row read as separate controls only with a mark between them. */
2739
+ .quiet-sep {
2740
+ font-size: 11px;
2741
+ line-height: 20px;
2742
+ color: var(--md-sys-color-outline, #94a3b8);
2743
+ user-select: none;
2744
+ }
2745
+ .quiet:focus-visible {
2746
+ outline: 2px solid var(--md-sys-color-primary, #0f172a);
2747
+ outline-offset: 2px;
2748
+ border-radius: 2px;
2749
+ }
2750
+
2727
2751
  /* ── Inline examples (action row 의 "예시" 토글 시) ──── */
2728
2752
  .inline-examples {
2729
2753
  padding: 4px 16px 0;
@@ -2771,11 +2795,32 @@ export class OxAssistantChat extends LitElement {
2771
2795
  .reply-preview strong { display: block; font-size: 12px; }
2772
2796
  .reply-preview span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
2773
2797
  .reply-preview button { border: 0; background: transparent; color: inherit; cursor: pointer; }
2798
+ /* Said only when a message goes to people - the AI is who a message goes to otherwise, and that needs no label. */
2774
2799
  .send-destination {
2775
- font-size: 11px;
2800
+ display: flex;
2801
+ align-items: center;
2802
+ gap: 6px;
2803
+ margin: 2px 14px 0;
2804
+ font-size: 11.5px;
2776
2805
  color: var(--md-sys-color-on-surface-variant, #475569);
2777
- align-self: center;
2778
- max-width: 64px;
2806
+ }
2807
+ .send-destination md-icon {
2808
+ --md-icon-size: 15px;
2809
+ }
2810
+ /* A notice that must not move the conversation: it floats over the bottom of the messages. */
2811
+ .toast {
2812
+ position: absolute;
2813
+ left: 50%;
2814
+ bottom: 120px;
2815
+ transform: translateX(-50%);
2816
+ z-index: 5;
2817
+ padding: 4px 12px;
2818
+ border-radius: 999px;
2819
+ background: var(--md-sys-color-inverse-surface, #1e293b);
2820
+ color: var(--md-sys-color-inverse-on-surface, #f8fafc);
2821
+ font-size: 11.5px;
2822
+ white-space: nowrap;
2823
+ pointer-events: none;
2779
2824
  }
2780
2825
  .input-row {
2781
2826
  display: flex;
@@ -2852,42 +2897,57 @@ export class OxAssistantChat extends LitElement {
2852
2897
  --md-icon-size: 15px;
2853
2898
  }
2854
2899
 
2855
- /* One send control, aligned with the resting input height. */
2856
- .input-row > button {
2857
- width: 54px;
2858
- height: 54px;
2900
+ /*
2901
+ * Send and attach, two flat buttons stacked beside the box - together they are the box's resting height (54), so
2902
+ * the row's top and bottom lines meet. Without attachments the send button stands alone at the bottom.
2903
+ */
2904
+ .send-stack {
2905
+ display: flex;
2906
+ flex-direction: column;
2907
+ justify-content: flex-end;
2908
+ gap: 4px;
2909
+ flex-shrink: 0;
2910
+ align-self: flex-end;
2911
+ }
2912
+ .send-stack > button {
2913
+ width: 40px;
2914
+ height: 25px;
2859
2915
  padding: 0;
2860
- background: var(--md-sys-color-primary, #1e293b);
2861
- color: var(--md-sys-color-on-primary, #ffffff);
2862
- border: 0;
2863
- border-radius: 12px;
2916
+ border-radius: 6px;
2864
2917
  cursor: pointer;
2865
2918
  display: flex;
2866
2919
  align-items: center;
2867
2920
  justify-content: center;
2868
2921
  transition:
2869
2922
  background 0.15s,
2870
- transform 0.1s,
2871
2923
  opacity 0.15s;
2872
- flex-shrink: 0;
2873
- align-self: flex-end;
2874
- margin-bottom: 0;
2875
2924
  }
2876
- .input-row > button:disabled {
2925
+ .send-stack > .send {
2926
+ background: var(--md-sys-color-primary, #1e293b);
2927
+ color: var(--md-sys-color-on-primary, #ffffff);
2928
+ border: 0;
2929
+ }
2930
+ .send-stack > .attach {
2931
+ background: transparent;
2932
+ color: var(--md-sys-color-on-surface-variant, #475569);
2933
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2934
+ }
2935
+ .send-stack > .send:disabled {
2877
2936
  background: var(--md-sys-color-surface-container-highest, #e2e8f0);
2878
2937
  color: var(--md-sys-color-outline, #94a3b8);
2879
2938
  cursor: not-allowed;
2880
2939
  }
2881
- .input-row > button:hover:not(:disabled) {
2940
+ .send-stack > .send:hover:not(:disabled) {
2882
2941
  opacity: 0.88;
2883
2942
  }
2884
- .input-row > button:active:not(:disabled) {
2885
- transform: scale(0.94);
2943
+ .send-stack > .attach:hover {
2944
+ background: var(--md-sys-color-surface-container, #f8fafc);
2945
+ color: var(--md-sys-color-on-surface, #0f172a);
2886
2946
  }
2887
- .input-row > button md-icon {
2888
- --md-icon-size: 18px;
2947
+ .send-stack md-icon {
2948
+ --md-icon-size: 16px;
2889
2949
  }
2890
- .input-row > button .spinner {
2950
+ .send-stack .spinner {
2891
2951
  width: 12px;
2892
2952
  height: 12px;
2893
2953
  border: 1.8px solid currentColor;
@@ -2911,6 +2971,8 @@ export class OxAssistantChat extends LitElement {
2911
2971
  /* 모드가 이미 붙어 있으면 무엇을 주는지 먼저 묻는다 — 바뀔 때는 updated 가 다시 묻는다. */
2912
2972
  if (this.mode?.kind) this.loadModeView()
2913
2973
  if (this.subject) this.onSubjectChanged()
2974
+ /* The domain's agents are read on every mount - a dock put back after closing sees an agent added meanwhile. */
2975
+ else void this.loadAgentChoices()
2914
2976
  /*
2915
2977
  * Put back after the dock was closed: what is being written stays, the conversation is read again and its
2916
2978
  * broadcast and presence start again - they were stopped when it was taken out.
@@ -2929,6 +2991,7 @@ export class OxAssistantChat extends LitElement {
2929
2991
  * 대화로 가면 그때 새로 난다(ADR-0072 A17 ①).
2930
2992
  */
2931
2993
  private async onSubjectChanged() {
2994
+ void this.loadAgentChoices()
2932
2995
  await this.loadModeChoices()
2933
2996
  if (this.modeChoices.length < 2 || !this.subject || !this.mode) return
2934
2997
 
@@ -3326,40 +3389,31 @@ export class OxAssistantChat extends LitElement {
3326
3389
  }
3327
3390
  }}
3328
3391
  />
3329
- ${this.acceptsAttachments
3330
- ? html`<button
3331
- type="button"
3332
- class="action"
3333
- @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}
3334
- title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
3335
- <md-icon>attach_file</md-icon>
3336
- ${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
3337
- </button>`
3338
- : nothing}
3339
3392
  ${hasMessages
3340
3393
  ? html`
3341
3394
  <button
3342
3395
  type="button"
3343
- class="action"
3396
+ class="quiet"
3397
+ aria-expanded=${this.examplesOpen ? 'true' : 'false'}
3344
3398
  @click=${() => (this.examplesOpen = !this.examplesOpen)}
3345
3399
  title=${i18next.t('ai-assistant.text.show-examples-tooltip')}>
3346
- <md-icon>${this.examplesOpen ? 'close' : 'lightbulb'}</md-icon>
3347
3400
  ${this.examplesOpen
3348
3401
  ? i18next.t('ai-assistant.button.close')
3349
3402
  : i18next.t('ai-assistant.button.examples')}
3350
3403
  </button>
3351
3404
  `
3352
3405
  : nothing}
3406
+ ${hasMessages ? html`<span class="quiet-sep" aria-hidden="true">·</span>` : nothing}
3353
3407
  <button
3354
3408
  type="button"
3355
- class="action composer-size"
3409
+ class="quiet composer-size"
3356
3410
  aria-pressed=${this.composerWide ? 'true' : 'false'}
3357
3411
  @click=${() => this.setComposerWide(!this.composerWide)}>
3358
- <md-icon>${this.composerWide ? 'close_fullscreen' : 'open_in_full'}</md-icon>
3359
3412
  ${this.composerWide
3360
3413
  ? i18next.t('ai-assistant.button.write-narrow', { defaultValue: 'Narrow' })
3361
3414
  : i18next.t('ai-assistant.button.write-wide', { defaultValue: 'Write wide' })}
3362
3415
  </button>
3416
+ ${this.renderAgentPicker()}
3363
3417
  </div>
3364
3418
  ${this.renderTablePreview()}
3365
3419
  ${this.examplesOpen ? this.renderInlineExamples() : nothing}
@@ -3408,6 +3462,13 @@ export class OxAssistantChat extends LitElement {
3408
3462
  @click=${() => this.cancelReply()}><md-icon>close</md-icon></button>
3409
3463
  </div>` : nothing}
3410
3464
  ${this.renderInviteNotice()}
3465
+ ${this.addressesPeople
3466
+ ? html`<div class="send-destination" aria-live="polite">
3467
+ <md-icon>group</md-icon>${i18next.t('ai-assistant.text.send-to-people-notice', {
3468
+ defaultValue: 'This goes to the people in this conversation. The AI does not answer it.'
3469
+ })}
3470
+ </div>`
3471
+ : nothing}
3411
3472
  <div class="input-row">
3412
3473
  <div class="textarea-wrap">
3413
3474
  <textarea
@@ -3421,9 +3482,7 @@ export class OxAssistantChat extends LitElement {
3421
3482
  }}
3422
3483
  ?disabled=${this.busy}
3423
3484
  rows="1"
3424
- placeholder=${this.conversation.placeholder ??
3425
- this.placeholder ??
3426
- i18next.t('ai-assistant.text.input-placeholder')}></textarea>
3485
+ placeholder=${this.suggestion ?? this.conversation.placeholder ?? this.placeholder ?? i18next.t('ai-assistant.text.input-placeholder')}></textarea>
3427
3486
  ${this.mentionOpen
3428
3487
  ? html`
3429
3488
  <ox-mention-popup
@@ -3444,12 +3503,12 @@ export class OxAssistantChat extends LitElement {
3444
3503
  `
3445
3504
  : nothing}
3446
3505
  </div>
3447
- <span class="send-destination" aria-live="polite">${i18next.t(this.addressesPeople
3448
- ? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}</span>
3449
3506
  ${this.composerWide
3450
3507
  ? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
3451
3508
  : nothing}
3509
+ <div class="send-stack">
3452
3510
  <button
3511
+ class="send"
3453
3512
  ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3454
3513
  @click=${() => this.submitMessage()}
3455
3514
  aria-label=${i18next.t(this.addressesPeople ? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}
@@ -3461,6 +3520,17 @@ export class OxAssistantChat extends LitElement {
3461
3520
  ? html`<span class="spinner"></span>`
3462
3521
  : html`<md-icon>arrow_upward</md-icon>`}
3463
3522
  </button>
3523
+ ${this.acceptsAttachments
3524
+ ? html`<button
3525
+ type="button"
3526
+ class="attach"
3527
+ aria-label=${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
3528
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}
3529
+ @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}>
3530
+ <md-icon>attach_file</md-icon>
3531
+ </button>`
3532
+ : nothing}
3533
+ </div>
3464
3534
  </div>
3465
3535
  </div>
3466
3536
  `
@@ -3691,8 +3761,13 @@ export class OxAssistantChat extends LitElement {
3691
3761
  return
3692
3762
  }
3693
3763
  }
3694
- const action = composerKeyAction(e, this.composerWide)
3695
- if (action === 'narrow') {
3764
+ const action = composerKeyAction(e, this.composerWide, { empty: !this.input, suggestion: this.suggestion })
3765
+ if (action === 'take-suggestion') {
3766
+ e.preventDefault()
3767
+ this.input = this.suggestion!
3768
+ const ta = e.target as HTMLTextAreaElement
3769
+ this.updateComplete.then(() => ta.setSelectionRange(ta.value.length, ta.value.length))
3770
+ } else if (action === 'narrow') {
3696
3771
  /* The Escape that narrows the box is spent here - the floating dock closes on the next one. */
3697
3772
  e.preventDefault()
3698
3773
  e.stopPropagation()
@@ -3703,6 +3778,11 @@ export class OxAssistantChat extends LitElement {
3703
3778
  }
3704
3779
  }
3705
3780
 
3781
+ /** The request a Tab writes into the empty box - `composerSuggestion` decides it. */
3782
+ private get suggestion(): string | undefined {
3783
+ return composerSuggestion({ started: this.lines.length > 0, modeExamples: this.modeView?.examples, hostExamples: this.examples })
3784
+ }
3785
+
3706
3786
  /** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
3707
3787
  * big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
3708
3788
  private onTextareaInput(e: Event) {
@@ -4003,15 +4083,6 @@ export class OxAssistantChat extends LitElement {
4003
4083
  }))
4004
4084
  }
4005
4085
 
4006
- /** 이 표면이 실제로 쓸 수 있는 트리거 — 판정은 `chat-triggers.ts` 가 하고 여기서는 사실만 읽는다. */
4007
- private get triggerChips(): TriggerChip[] {
4008
- return availableTriggers({
4009
- hasMentionableThings: !!(this.boardProvider || this.currentBoard || this.knownTypes?.length),
4010
- hasSession: !!this.sessionId,
4011
- slashTemplateCount: this.conversation.slashTemplates.length
4012
- })
4013
- }
4014
-
4015
4086
  private static readonly EXAMPLE_GROUPS: Array<{
4016
4087
  labelKey: string
4017
4088
  itemKeys: string[]
@@ -4065,12 +4136,34 @@ export class OxAssistantChat extends LitElement {
4065
4136
  */
4066
4137
  private renderModeSubject() {
4067
4138
  if (this.modeChoices.length < 2) return nothing
4139
+ return html`<div class="mode-subject">${this.renderModePicker()}</div>`
4140
+ }
4068
4141
 
4142
+ /**
4143
+ * Which AI answers the next turn - at the end of the row above the box, where the words about writing stand. A row of
4144
+ * its own spent a line of the dock on one control. It is a property of the turn, not of the conversation: switching
4145
+ * keeps the conversation and only the next answer comes from the other agent. Drawn only when there is a choice.
4146
+ */
4147
+ private renderAgentPicker() {
4148
+ if (!offersAgentPicker(this.agentChoices)) return nothing
4149
+ const current = this.agentChoices.find(one => one.id === this.agentId)
4150
+ return html`
4151
+ <select
4152
+ class="mode-select agent-select"
4153
+ aria-label=${i18next.t('ai-assistant.label.ai-agent', { defaultValue: 'AI agent' })}
4154
+ title=${current ? agentDetail(current) : ''}
4155
+ ?disabled=${this.busy}
4156
+ @change=${(event: Event) => this.onAgentPick((event.target as HTMLSelectElement).value)}>
4157
+ ${this.agentChoices.map(one => html`<option value=${one.id} ?selected=${one.id === this.agentId}>${one.name}</option>`)}
4158
+ </select>
4159
+ `
4160
+ }
4161
+
4162
+ private renderModePicker() {
4069
4163
  const current = this.modeChoices.find(choice => choice.kind === this.mode?.kind)
4070
4164
  const others = this.modeChoices.filter(choice => choice.kind !== this.mode?.kind)
4071
4165
 
4072
4166
  return html`
4073
- <div class="mode-subject">
4074
4167
  <span class="mode-now">${current?.label ?? this.mode?.kind ?? ''}</span>
4075
4168
  ${others.length === 1
4076
4169
  ? html`
@@ -4094,7 +4187,6 @@ export class OxAssistantChat extends LitElement {
4094
4187
  )}
4095
4188
  </select>
4096
4189
  `}
4097
- </div>
4098
4190
  `
4099
4191
  }
4100
4192
 
@@ -4166,27 +4258,6 @@ export class OxAssistantChat extends LitElement {
4166
4258
  </div>
4167
4259
  </div>
4168
4260
 
4169
- ${this.triggerChips.length > 0
4170
- ? html`
4171
- <div class="trigger-row">
4172
- <div class="trigger-row-label">${i18next.t('ai-assistant.text.triggers-heading')}</div>
4173
- <div class="trigger-chips">
4174
- ${this.triggerChips.map(
4175
- trigger => html`
4176
- <button
4177
- class="trigger-chip"
4178
- title=${i18next.t(trigger.tooltipKey)}
4179
- @click=${() => this.insertTrigger(trigger.char)}>
4180
- <span class="trigger-char">${trigger.char}</span>
4181
- <span class="trigger-desc">${i18next.t(trigger.labelKey)}</span>
4182
- </button>
4183
- `
4184
- )}
4185
- </div>
4186
- </div>
4187
- `
4188
- : ''}
4189
-
4190
4261
  ${this.exampleGroups.map(
4191
4262
  group => html`
4192
4263
  <div class="group">
@@ -4202,33 +4273,11 @@ export class OxAssistantChat extends LitElement {
4202
4273
  `
4203
4274
  )}
4204
4275
 
4205
- <div class="footer">
4206
- <span class="badge">${i18next.t('ai-assistant.label.korean-supported')}</span>
4207
- <span class="badge">${i18next.t('ai-assistant.label.multi-command')}</span>
4208
- <span class="badge">${i18next.t('ai-assistant.label.review-able')}</span>
4209
- ${this.conversation.footerNotice
4210
- ? html`<br />${this.conversation.footerNotice}`
4211
- : ''}
4212
- </div>
4276
+ ${this.conversation.footerNotice ? html`<div class="footer">${this.conversation.footerNotice}</div>` : ''}
4213
4277
  </div>
4214
4278
  `
4215
4279
  }
4216
4280
 
4217
- /** trigger chip 클릭 — textarea 에 trigger 문자 삽입 + focus + popup 자동 활성. */
4218
- private insertTrigger(trigger: '#' | '@' | '/') {
4219
- // 입력 끝에 공백이 없으면 공백 추가 후 trigger — 자연스러운 위치
4220
- const needsSpace = this.input.length > 0 && !/\s$/.test(this.input)
4221
- this.input = this.input + (needsSpace ? ' ' : '') + trigger
4222
- requestAnimationFrame(() => {
4223
- const ta = this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null
4224
- if (!ta) return
4225
- ta.focus()
4226
- ta.setSelectionRange(ta.value.length, ta.value.length)
4227
- // popup refresh — onTextareaInput 이 디바운스 후 호출되지만 직접 호출로 즉각 반영
4228
- this.refreshMentionPopup(ta)
4229
- })
4230
- }
4231
-
4232
4281
  /** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
4233
4282
  private useExample(text: string) {
4234
4283
  this.input = text
@@ -4941,8 +4990,8 @@ export class OxAssistantChat extends LitElement {
4941
4990
  try {
4942
4991
  // 클립보드에는 marker 제거된 깨끗한 텍스트
4943
4992
  await navigator.clipboard.writeText(stripMentionRefids(content))
4993
+ /* The button turns into a check where it was pressed - that is the whole answer. */
4944
4994
  this.copiedIdx = idx
4945
- this.showToast(i18next.t('ai-assistant.text.copied'))
4946
4995
  setTimeout(() => {
4947
4996
  if (this.copiedIdx === idx) this.copiedIdx = undefined
4948
4997
  }, 1500)
@@ -5168,7 +5217,8 @@ export class OxAssistantChat extends LitElement {
5168
5217
  mode: { kind: this.conversation.kind!, target: this.conversation.target },
5169
5218
  truncateAfterMessageId: this._truncateAfterId,
5170
5219
  hostContext: requestContext.hostContext,
5171
- attachmentIds
5220
+ attachmentIds,
5221
+ agentId: this.agentId
5172
5222
  })
5173
5223
  : buildChatMutationInput({
5174
5224
  sessionId: this.sessionId,
@@ -5187,7 +5237,8 @@ export class OxAssistantChat extends LitElement {
5187
5237
  selectedRefids: this.selectedRefids ?? [],
5188
5238
  mentions,
5189
5239
  /* With a declared mode the server owns persona and tools; the board door still carries the board. */
5190
- mode: this.conversation.kind ? { kind: this.conversation.kind, target: this.conversation.target } : undefined
5240
+ mode: this.conversation.kind ? { kind: this.conversation.kind, target: this.conversation.target } : undefined,
5241
+ agentId: this.agentId
5191
5242
  })
5192
5243
  }
5193
5244
  })