@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,252 @@
1
+ import '@things-factory/setting-base/dist-client/layout/settinglet.js'
2
+ import '@operato/input/ox-input-select-buttons.js'
3
+
4
+ import gql from 'graphql-tag'
5
+ import { css, html, LitElement, nothing } from 'lit'
6
+ import { customElement, state } from 'lit/decorators.js'
7
+ import { client } from '@operato/graphql'
8
+ import { i18next, localize } from '@operato/i18n'
9
+ import { FormFieldStyles, ScrollbarStyles } from '@operato/styles'
10
+
11
+ import { periodRange, totalsOf, type UsageBucketSize, type UsagePeriod, type UsageRow } from './usage-model.js'
12
+
13
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
14
+ i18next.t(`ai-assistant.usage.${key}`, { ...options, defaultValue })
15
+
16
+ const USAGE_QUERY = gql`
17
+ query AiUsageSummary($from: DateTimeISO!, $to: DateTimeISO!, $bucket: String!, $includeDescendants: Boolean) {
18
+ aiUsageSummary(from: $from, to: $to, bucket: $bucket, includeDescendants: $includeDescendants) {
19
+ bucket
20
+ agentId
21
+ agentName
22
+ provider
23
+ model
24
+ domainId
25
+ domainName
26
+ userId
27
+ userName
28
+ calls
29
+ inputTokens
30
+ outputTokens
31
+ failures
32
+ }
33
+ }
34
+ `
35
+
36
+ /**
37
+ * AI usage of this domain - per hour, day or month × agent × person, in the domain's time zone
38
+ * (design/ai/agent-settings.md §7-3). With "domains below", what they did with this domain's agents is added as one
39
+ * line per domain; their people are not named here.
40
+ */
41
+ @customElement('ai-usage-setting-let')
42
+ export class AiUsageSettingLet extends localize(i18next)(LitElement) {
43
+ static styles = [
44
+ FormFieldStyles,
45
+ ScrollbarStyles,
46
+ css`
47
+ :host {
48
+ display: block;
49
+ }
50
+ [slot='content'] {
51
+ display: grid;
52
+ gap: 8px;
53
+ text-transform: none;
54
+ }
55
+ .filters {
56
+ display: flex;
57
+ flex-wrap: wrap;
58
+ align-items: center;
59
+ gap: 8px 12px;
60
+ }
61
+ .filters select {
62
+ height: var(--form-element-height-small, 26px);
63
+ font-size: 12px;
64
+ }
65
+ .filters label {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ gap: 4px;
69
+ font-size: 12px;
70
+ }
71
+ .table {
72
+ max-height: 360px;
73
+ overflow: auto;
74
+ }
75
+ table {
76
+ width: 100%;
77
+ border-collapse: collapse;
78
+ font-size: 12px;
79
+ }
80
+ th,
81
+ td {
82
+ padding: 4px 8px;
83
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
84
+ white-space: nowrap;
85
+ text-align: left;
86
+ }
87
+ th {
88
+ position: sticky;
89
+ top: 0;
90
+ background: var(--md-sys-color-surface-container);
91
+ font-weight: 600;
92
+ color: var(--md-sys-color-on-surface-variant);
93
+ }
94
+ td.number,
95
+ th.number {
96
+ text-align: right;
97
+ font-variant-numeric: tabular-nums;
98
+ }
99
+ td.failed {
100
+ color: var(--md-sys-color-error);
101
+ }
102
+ tr.total td {
103
+ font-weight: 600;
104
+ border-top: 2px solid var(--md-sys-color-outline-variant);
105
+ }
106
+ .below {
107
+ color: var(--md-sys-color-on-surface-variant);
108
+ }
109
+ .state {
110
+ font-size: 12px;
111
+ color: var(--md-sys-color-on-surface-variant);
112
+ }
113
+ .state.failed {
114
+ color: var(--md-sys-color-error);
115
+ }
116
+ `
117
+ ]
118
+
119
+ @state() private period: UsagePeriod = '7d'
120
+ @state() private bucket: UsageBucketSize = 'day'
121
+ @state() private includeDescendants = false
122
+ @state() private agentFilter = ''
123
+ @state() private userFilter = ''
124
+ @state() private rows?: UsageRow[]
125
+ @state() private failed?: string
126
+
127
+ connectedCallback() {
128
+ super.connectedCallback()
129
+ this.load()
130
+ }
131
+
132
+ private loadToken = 0
133
+
134
+ private async load() {
135
+ const token = ++this.loadToken
136
+ this.failed = undefined
137
+ this.rows = undefined
138
+ const { from, to } = periodRange(this.period, new Date())
139
+ try {
140
+ const response = await client.query({
141
+ query: USAGE_QUERY,
142
+ fetchPolicy: 'network-only',
143
+ variables: { from, to, bucket: this.bucket, includeDescendants: this.includeDescendants }
144
+ })
145
+ if (token !== this.loadToken) return
146
+ /* This client answers a refusal in `errors` (errorPolicy 'all') instead of throwing. */
147
+ if ((response as any).errors?.length) throw new Error((response as any).errors[0].message)
148
+ this.rows = response.data?.aiUsageSummary ?? []
149
+ } catch (error: any) {
150
+ if (token !== this.loadToken) return
151
+ this.failed = error?.graphQLErrors?.[0]?.message ?? error?.message ?? String(error)
152
+ }
153
+ }
154
+
155
+ render() {
156
+ return html`
157
+ <setting-let>
158
+ <div slot="title">${t('title', 'AI usage')}</div>
159
+ <div slot="content">${this.renderFilters()} ${this.renderTable()}</div>
160
+ </setting-let>
161
+ `
162
+ }
163
+
164
+ private renderFilters() {
165
+ const rows = this.rows ?? []
166
+ const agents = [...new Map(rows.filter(one => one.agentId).map(one => [one.agentId!, one.agentName ?? t('deleted-agent', '(deleted agent)')])).entries()]
167
+ const users = [...new Map(rows.filter(one => one.userId).map(one => [one.userId!, one.userName ?? one.userId!])).entries()]
168
+ return html`
169
+ <div class="filters">
170
+ <ox-input-select-buttons
171
+ .value=${this.period}
172
+ .options=${[
173
+ { display: t('today', 'Today'), value: 'today' },
174
+ { display: t('7d', '7 days'), value: '7d' },
175
+ { display: t('30d', '30 days'), value: '30d' }
176
+ ]}
177
+ @change=${(e: CustomEvent) => e.detail && ((this.period = e.detail), this.load())}></ox-input-select-buttons>
178
+ <ox-input-select-buttons
179
+ .value=${this.bucket}
180
+ .options=${[
181
+ { display: t('by-hour', 'Hour'), value: 'hour' },
182
+ { display: t('by-day', 'Day'), value: 'day' },
183
+ { display: t('by-month', 'Month'), value: 'month' }
184
+ ]}
185
+ @change=${(e: CustomEvent) => e.detail && ((this.bucket = e.detail), this.load())}></ox-input-select-buttons>
186
+ <select aria-label=${t('agent', 'Agent')} @change=${(e: Event) => (this.agentFilter = (e.target as HTMLSelectElement).value)}>
187
+ <option value="">${t('all-agents', 'All agents')}</option>
188
+ ${agents.map(([id, name]) => html`<option value=${id} ?selected=${id === this.agentFilter}>${name}</option>`)}
189
+ </select>
190
+ <select aria-label=${t('person', 'Person')} @change=${(e: Event) => (this.userFilter = (e.target as HTMLSelectElement).value)}>
191
+ <option value="">${t('all-people', 'Everyone')}</option>
192
+ ${users.map(([id, name]) => html`<option value=${id} ?selected=${id === this.userFilter}>${name}</option>`)}
193
+ </select>
194
+ <label>
195
+ <input type="checkbox" .checked=${this.includeDescendants} @change=${(e: Event) => ((this.includeDescendants = (e.target as HTMLInputElement).checked), this.load())} />
196
+ ${t('include-below', 'Domains below')}
197
+ </label>
198
+ </div>
199
+ `
200
+ }
201
+
202
+ private renderTable() {
203
+ if (this.failed) return html`<div class="state failed">${t('load-failed', 'Could not read the usage.')} ${this.failed}</div>`
204
+ if (!this.rows) return html`<div class="state">${t('loading', 'Reading…')}</div>`
205
+
206
+ const rows = this.rows.filter(one => (!this.agentFilter || one.agentId === this.agentFilter) && (!this.userFilter || one.userId === this.userFilter))
207
+ if (!rows.length) return html`<div class="state">${t('empty', 'No AI was used in this period.')}</div>`
208
+
209
+ const total = totalsOf(rows)
210
+ const number = (value: number) => value.toLocaleString()
211
+ return html`
212
+ <div class="table">
213
+ <table>
214
+ <thead>
215
+ <tr>
216
+ <th>${t('when', 'When')}</th>
217
+ <th>${t('agent', 'Agent')}</th>
218
+ <th>${t('person', 'Person')}</th>
219
+ <th class="number">${t('calls', 'Calls')}</th>
220
+ <th class="number">${t('input-tokens', 'Input tokens')}</th>
221
+ <th class="number">${t('output-tokens', 'Output tokens')}</th>
222
+ <th class="number">${t('failures', 'Failed')}</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ ${rows.map(
227
+ row => html`<tr>
228
+ <td>${row.bucket}</td>
229
+ <td title=${`${row.provider} · ${row.model}`}>${row.agentName ?? t('deleted-agent', '(deleted agent)')}</td>
230
+ <td>
231
+ ${row.userName ??
232
+ (row.userId ? row.userId : nothing)}${!row.userId && row.domainName ? html`<span class="below">(${row.domainName})</span>` : nothing}
233
+ </td>
234
+ <td class="number">${number(row.calls)}</td>
235
+ <td class="number">${number(row.inputTokens)}</td>
236
+ <td class="number">${number(row.outputTokens)}</td>
237
+ <td class="number ${row.failures ? 'failed' : ''}">${number(row.failures)}</td>
238
+ </tr>`
239
+ )}
240
+ <tr class="total">
241
+ <td colspan="3">${t('total', 'Total')}</td>
242
+ <td class="number">${number(total.calls)}</td>
243
+ <td class="number">${number(total.inputTokens)}</td>
244
+ <td class="number">${number(total.outputTokens)}</td>
245
+ <td class="number">${number(total.failures)}</td>
246
+ </tr>
247
+ </tbody>
248
+ </table>
249
+ </div>
250
+ `
251
+ }
252
+ }
@@ -0,0 +1,46 @@
1
+ /** What the AI usage card asks for and adds up (design/ai/agent-settings.md §7-3). */
2
+ export type UsagePeriod = 'today' | '7d' | '30d'
3
+ export type UsageBucketSize = 'hour' | 'day' | 'month'
4
+
5
+ export interface UsageRow {
6
+ bucket: string
7
+ agentId?: string | null
8
+ agentName?: string | null
9
+ provider: string
10
+ model: string
11
+ domainId: string
12
+ domainName: string
13
+ userId?: string | null
14
+ userName?: string | null
15
+ calls: number
16
+ inputTokens: number
17
+ outputTokens: number
18
+ failures: number
19
+ }
20
+
21
+ /**
22
+ * The span a period covers, up to the end of the current hour - usage is stored by the hour, so the hour in progress
23
+ * is counted whole.
24
+ */
25
+ export function periodRange(period: UsagePeriod, now: Date): { from: Date; to: Date } {
26
+ const to = new Date(now)
27
+ to.setMinutes(0, 0, 0)
28
+ to.setHours(to.getHours() + 1)
29
+ const from = new Date(now)
30
+ from.setHours(0, 0, 0, 0)
31
+ if (period === '7d') from.setDate(from.getDate() - 6)
32
+ if (period === '30d') from.setDate(from.getDate() - 29)
33
+ return { from, to }
34
+ }
35
+
36
+ export function totalsOf(rows: UsageRow[]) {
37
+ return rows.reduce(
38
+ (sum, row) => ({
39
+ calls: sum.calls + row.calls,
40
+ inputTokens: sum.inputTokens + row.inputTokens,
41
+ outputTokens: sum.outputTokens + row.outputTokens,
42
+ failures: sum.failures + row.failures
43
+ }),
44
+ { calls: 0, inputTokens: 0, outputTokens: 0, failures: 0 }
45
+ )
46
+ }
@@ -117,7 +117,7 @@ export class AssistantDockButton extends LitElement {
117
117
 
118
118
  let installed = false
119
119
 
120
- export function setupAssistantDock(options: { viewpart?: string; overlayBoundary?: () => Element | undefined } = {}): void {
120
+ export function setupAssistantDock(options: { viewpart?: string } = {}): void {
121
121
  if (installed) return
122
122
  installed = true
123
123
  const viewpart = options.viewpart ?? ASSISTANT_DOCK_VIEWPART
@@ -129,9 +129,8 @@ export function setupAssistantDock(options: { viewpart?: string; overlayBoundary
129
129
  * makes the element would make a new dock on every opening - and a new composer, the text being written gone
130
130
  * (design/ai/shared-dock.md §2-1, §2-7). The same element is put back instead, as the entity360 dock is.
131
131
  */
132
- const dock = document.createElement('ox-assistant-dock') as HTMLElement & { viewpart: string; overlayBoundary?: () => Element | undefined }
132
+ const dock = document.createElement('ox-assistant-dock') as HTMLElement & { viewpart: string }
133
133
  dock.viewpart = viewpart
134
- dock.overlayBoundary = options.overlayBoundary
135
134
 
136
135
  appendViewpart({
137
136
  name: viewpart,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Which AI agent the dock sends a turn with (design/ai/agent-settings.md §5).
3
+ *
4
+ * The server lists the agents a person may pick in this domain (`aiAgentChoices`); the dock sends the id of one, and
5
+ * the server decides the provider, the model and the key from it. What a person last picked is a convenience of this
6
+ * browser, kept per domain: an id the list no longer has is dropped and the domain's default answers.
7
+ */
8
+ export interface AgentChoice {
9
+ id: string
10
+ name: string
11
+ provider: string
12
+ model: string
13
+ isDefault: boolean
14
+ }
15
+
16
+ /** The agent to send with: the remembered one while the domain still offers it, else the default, else the first. */
17
+ export function pickAgentId(choices: AgentChoice[], remembered?: string | null): string | undefined {
18
+ if (remembered && choices.some(one => one.id === remembered)) return remembered
19
+ return (choices.find(one => one.isDefault) ?? choices[0])?.id
20
+ }
21
+
22
+ /** A picker is drawn only when there is something to pick (the same rule as the mode picker, ADR-0072). */
23
+ export function offersAgentPicker(choices: AgentChoice[]): boolean {
24
+ return choices.length >= 2
25
+ }
26
+
27
+ export function agentMemoryKey(contextPath: string | undefined): string {
28
+ return `assistant-agent:${contextPath ?? ''}`
29
+ }
30
+
31
+ export function rememberedAgent(contextPath: string | undefined): string | undefined {
32
+ try {
33
+ return localStorage.getItem(agentMemoryKey(contextPath)) ?? undefined
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ export function rememberAgent(contextPath: string | undefined, agentId: string): void {
40
+ try {
41
+ localStorage.setItem(agentMemoryKey(contextPath), agentId)
42
+ } catch {
43
+ /* A browser that blocks storage still picks - it just asks again next time. */
44
+ }
45
+ }
46
+
47
+ const PROVIDER_LABEL: Record<string, string> = { openai: 'OpenAI', anthropic: 'Claude', gemini: 'Gemini' }
48
+
49
+ /** `Claude · claude-sonnet-4-6` - what the picker shows beside a name. */
50
+ export function agentDetail(choice: Pick<AgentChoice, 'provider' | 'model'>): string {
51
+ return `${PROVIDER_LABEL[choice.provider] ?? choice.provider} · ${choice.model}`
52
+ }
53
+
54
+ export function providerLabel(provider: string): string {
55
+ return PROVIDER_LABEL[provider] ?? provider
56
+ }
@@ -2,25 +2,31 @@ import { surfaceTransition } from './assistant-navigation.js'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
 
5
- describe('a screen offers context without taking over an active conversation', () => {
6
- test.each([false, true])('a different identity resets even when active=%s', active => {
5
+ describe('the page decides the conversation - no choice is put to the person', () => {
6
+ test.each([false, true])('a different identity resets even when busy=%s', busy => {
7
7
  for (const sameSubject of [false, true]) {
8
- expect(surfaceTransition({ identityChanged: true, sameSubject, active })).toBe('reset')
8
+ expect(surfaceTransition({ identityChanged: true, sameSubject, busy })).toBe('reset')
9
9
  }
10
10
  })
11
- test('an empty conversation follows the screen', () => {
12
- expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: false })).toBe('follow')
11
+ test('★ another screen is followed - a conversation in progress or text being written does not hold the dock', () => {
12
+ expect(surfaceTransition({ identityChanged: false, sameSubject: false, busy: false })).toBe('follow')
13
13
  })
14
- test('an existing conversation, draft or ongoing response offers a switch', () => {
15
- expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: true })).toBe('offer')
14
+ test('★ an answer in progress finishes in its own conversation first, then the dock follows', () => {
15
+ expect(surfaceTransition({ identityChanged: false, sameSubject: false, busy: true })).toBe('wait')
16
16
  })
17
17
  test('returning to the same subject refreshes without reopening', () => {
18
- expect(surfaceTransition({ identityChanged: false, sameSubject: true, active: true })).toBe('refresh')
18
+ expect(surfaceTransition({ identityChanged: false, sameSubject: true, busy: true })).toBe('refresh')
19
19
  })
20
20
  })
21
21
 
22
22
  describe('navigation wiring', () => {
23
23
  const dock = readFileSync(join(__dirname, '../components/assistant-dock.ts'), 'utf8')
24
+ test('★ what was being written stays with its subject and comes back on return', () => {
25
+ expect(dock).toMatch(/if \(transition !== 'reset'\) this\.keepDraft\(\)/)
26
+ expect(dock).toContain('void this.restoreDraft()')
27
+ expect(dock).not.toContain('screenChanged')
28
+ expect(dock).not.toContain('switch-to-screen')
29
+ })
24
30
  test('history hides rather than unmounts the chat and its unsent input', () => {
25
31
  expect(dock).toContain('?hidden=${this.showingHistory}')
26
32
  expect(dock).toContain('ready && !this.loading && !this.failure')
@@ -1,6 +1,11 @@
1
- /** Screen changes offer a new subject; identity changes must never carry the old conversation. */
2
- export function surfaceTransition(input: { identityChanged: boolean; sameSubject: boolean; active: boolean }): 'reset' | 'refresh' | 'offer' | 'follow' {
1
+ /**
2
+ * The page decides the conversation (design/ai/shared-dock.md §2-3, 2026-10-12): moving to another screen moves the
3
+ * dock to that screen's conversation, with no choice put to the person. What was being written stays with its subject
4
+ * and comes back on return. An answer in progress finishes first - then the dock follows. An identity change never
5
+ * carries the old conversation.
6
+ */
7
+ export function surfaceTransition(input: { identityChanged: boolean; sameSubject: boolean; busy: boolean }): 'reset' | 'refresh' | 'wait' | 'follow' {
3
8
  if (input.identityChanged) return 'reset'
4
9
  if (input.sameSubject) return 'refresh'
5
- return input.active ? 'offer' : 'follow'
10
+ return input.busy ? 'wait' : 'follow'
6
11
  }
@@ -20,8 +20,19 @@ export function composerHeight(content: number, chat: number, resting: number):
20
20
 
21
21
  export type ComposerKey = { key: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
22
22
 
23
- /** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */
24
- export function composerKeyAction(e: ComposerKey, wide: boolean): 'send' | 'narrow' | null {
23
+ /**
24
+ * What a key does in the box: send, close the wide box, take the suggestion, or nothing of ours (the textarea keeps
25
+ * it). The words shown in an empty box are a request the screen suggests - a plain Tab writes them in, so the person
26
+ * does not have to type what the screen already said.
27
+ */
28
+ export function composerKeyAction(
29
+ e: ComposerKey,
30
+ wide: boolean,
31
+ box: { empty?: boolean; suggestion?: string } = {}
32
+ ): 'send' | 'narrow' | 'take-suggestion' | null {
33
+ if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && box.empty && box.suggestion?.trim()) {
34
+ return 'take-suggestion'
35
+ }
25
36
  if (e.key === 'Escape') return wide ? 'narrow' : null
26
37
  if (e.key !== 'Enter' || e.altKey) return null
27
38
  if (e.ctrlKey || e.metaKey) return 'send'
@@ -47,3 +58,18 @@ export function tableAsWords(table: { rows: string[][]; text?: string | null }):
47
58
  export function appendWords(written: string, words: string): string {
48
59
  return written.trim() ? `${written.replace(/\s+$/, '')}\n${words}` : words
49
60
  }
61
+
62
+ /**
63
+ * The request the empty box suggests, which a Tab writes in: the conversation's first example request, its own
64
+ * before the host's. A placeholder is how to write, not a request, so it is never the suggestion. Once the
65
+ * conversation is under way an opening example no longer fits, and nothing is suggested.
66
+ */
67
+ export function composerSuggestion(input: {
68
+ started: boolean
69
+ modeExamples?: Array<{ items: string[] }>
70
+ hostExamples?: Array<{ items: string[] }>
71
+ }): string | undefined {
72
+ if (input.started) return undefined
73
+ const groups = input.modeExamples?.length ? input.modeExamples : input.hostExamples
74
+ return groups?.find(group => group.items.length > 0)?.items[0]
75
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The page's menu gives the dock its room (2026-10-12). When the dock opens beside the page, the left menu folds to its
3
+ * icons; when the dock closes, the menu opens again - only if it is still folded the way the dock left it. A menu the
4
+ * person folded themselves stays folded, and the dock's fold is never saved as the person's choice: it sets the menu's
5
+ * state without the menu's own `setCollapsed`, which stores it.
6
+ */
7
+ export interface MenuLike {
8
+ collapsed: boolean
9
+ }
10
+
11
+ export function foldForDock(menu: MenuLike | undefined): boolean {
12
+ if (!menu || menu.collapsed) return false
13
+ menu.collapsed = true
14
+ return true
15
+ }
16
+
17
+ export function unfoldAfterDock(menu: MenuLike | undefined, foldedByDock: boolean): void {
18
+ if (!menu || !foldedByDock || !menu.collapsed) return
19
+ menu.collapsed = false
20
+ }
21
+
22
+ /** The framework's menu (`ox-menu-part` of @operato/menu), wherever the layout put it. */
23
+ export function findMenu(root: Document | ShadowRoot = document): (HTMLElement & MenuLike) | undefined {
24
+ const found = root.querySelector('ox-menu-part') as (HTMLElement & MenuLike) | null
25
+ if (found) return found
26
+ for (const element of root.querySelectorAll('*')) {
27
+ if (element.shadowRoot) {
28
+ const inner = findMenu(element.shadowRoot)
29
+ if (inner) return inner
30
+ }
31
+ }
32
+ return undefined
33
+ }
@@ -0,0 +1,8 @@
1
+ import './settings/ai-agent-setting-let.js';
2
+ import './settings/ai-usage-setting-let.js';
3
+ /**
4
+ * Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›
5
+ * {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the
6
+ * tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.
7
+ */
8
+ export default function bootstrap(): void;
@@ -0,0 +1,26 @@
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
+ import './settings/ai-agent-setting-let.js';
6
+ import './settings/ai-usage-setting-let.js';
7
+ /**
8
+ * Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›
9
+ * {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the
10
+ * tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.
11
+ */
12
+ export default function bootstrap() {
13
+ auth.on('profile', async () => {
14
+ if (!(await hasPrivilege({ privilege: 'mutation', category: 'setting', domainOwnerGranted: true })))
15
+ return;
16
+ store.dispatch({
17
+ type: ADD_SETTING,
18
+ setting: { seq: 60, scope: 'tenant', template: html `<ai-agent-setting-let></ai-agent-setting-let>` }
19
+ });
20
+ store.dispatch({
21
+ type: ADD_SETTING,
22
+ setting: { seq: 61, scope: 'tenant', template: html `<ai-usage-setting-let></ai-usage-setting-let>` }
23
+ });
24
+ });
25
+ }
26
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../client/bootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,CAAA;AAC1B,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,gDAAgD,CAAA;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,mDAAmD,CAAA;AAE/E,OAAO,oCAAoC,CAAA;AAC3C,OAAO,oCAAoC,CAAA;AAE3C;;;;GAIG;AACH,MAAM,CAAC,OAAO,UAAU,SAAS;IAC/B,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE;QAC5B,IAAI,CAAC,CAAC,MAAM,YAAY,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC;YAAE,OAAM;QAE3G,KAAK,CAAC,QAAQ,CAAC;YACb,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAA,+CAA+C,EAAE;SACrG,CAAC,CAAA;QACF,KAAK,CAAC,QAAQ,CAAC;YACb,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAA,+CAA+C,EAAE;SACrG,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import { html } from 'lit'\nimport { store } from '@operato/shell'\nimport { auth, hasPrivilege } from '@things-factory/auth-base/dist-client/index.js'\nimport { ADD_SETTING } from '@things-factory/setting-base/dist-client/index.js'\n\nimport './settings/ai-agent-setting-let.js'\nimport './settings/ai-usage-setting-let.js'\n\n/**\n * Every application that carries the AI assistant gets its domain's AI agents and AI usage in Settings ›\n * {domain} settings (design/ai/agent-settings.md §7). They are the domain's, not the person's, so they go on the\n * tenant tab - and only for someone who may change the domain's settings, as the doors behind them require.\n */\nexport default function bootstrap() {\n auth.on('profile', async () => {\n if (!(await hasPrivilege({ privilege: 'mutation', category: 'setting', domainOwnerGranted: true }))) return\n\n store.dispatch({\n type: ADD_SETTING,\n setting: { seq: 60, scope: 'tenant', template: html`<ai-agent-setting-let></ai-agent-setting-let>` }\n })\n store.dispatch({\n type: ADD_SETTING,\n setting: { seq: 61, scope: 'tenant', template: html`<ai-usage-setting-let></ai-usage-setting-let>` }\n })\n })\n}\n"]}
@@ -298,6 +298,18 @@ export declare class OxAssistantChat extends LitElement {
298
298
  busy: boolean;
299
299
  draft: boolean;
300
300
  };
301
+ /**
302
+ * What is being written, handed to the host when the page moves to another subject - it belongs to the conversation
303
+ * it was written in, and comes back with `restoreDraft` when the person returns. The box is left empty.
304
+ */
305
+ takeDraft(): {
306
+ text: string;
307
+ attachments: ChatAttachment[];
308
+ } | undefined;
309
+ restoreDraft(draft: {
310
+ text: string;
311
+ attachments: ChatAttachment[];
312
+ }): void;
301
313
  private errorMessage?;
302
314
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
303
315
  private participants;
@@ -356,6 +368,17 @@ export declare class OxAssistantChat extends LitElement {
356
368
  /** 이 subject 에 설 수 있는 대화들 — 기본이 먼저. 하나뿐이면 고르개를 그리지 않는다. */
357
369
  private modeChoices;
358
370
  private modeChoicesToken;
371
+ /** The AI agents this domain lets a person pick (design/ai/agent-settings.md §5). One or none draws no picker. */
372
+ private agentChoices;
373
+ private agentId?;
374
+ private agentChoicesToken;
375
+ private get contextPath();
376
+ /**
377
+ * Asks which agents this domain offers. The list changes when someone edits the domain's agents, so it is read
378
+ * again whenever the conversation's subject is - never cached for the life of the page.
379
+ */
380
+ private loadAgentChoices;
381
+ private onAgentPick;
359
382
  /**
360
383
  * 이 화면에 설 수 있는 대화를 서버에 묻는다.
361
384
  *
@@ -453,6 +476,8 @@ export declare class OxAssistantChat extends LitElement {
453
476
  private tableShape;
454
477
  private renderTablePreview;
455
478
  private onKeyDown;
479
+ /** The request a Tab writes into the empty box - `composerSuggestion` decides it. */
480
+ private get suggestion();
456
481
  /** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
457
482
  * big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
458
483
  private onTextareaInput;
@@ -497,8 +522,6 @@ export declare class OxAssistantChat extends LitElement {
497
522
  * 읽었고, 그러면 호스트 값을 한쪽만 반영하는 상태가 만들어질 수 있었다.
498
523
  */
499
524
  private get exampleGroups();
500
- /** 이 표면이 실제로 쓸 수 있는 트리거 — 판정은 `chat-triggers.ts` 가 하고 여기서는 사실만 읽는다. */
501
- private get triggerChips();
502
525
  private static readonly EXAMPLE_GROUPS;
503
526
  /** 보드의 세션 탭 — 다중 세션 활성화 시 패널 상단. host 가 sessions 비워두면 표시 X.
504
527
  * 레이블은 session.name 우선, 없으면 fallback "세션 N". */
@@ -525,14 +548,19 @@ export declare class OxAssistantChat extends LitElement {
525
548
  * 돌아온다.
526
549
  */
527
550
  private renderModeSubject;
551
+ /**
552
+ * Which AI answers the next turn - at the end of the row above the box, where the words about writing stand. A row of
553
+ * its own spent a line of the dock on one control. It is a property of the turn, not of the conversation: switching
554
+ * keeps the conversation and only the next answer comes from the other agent. Drawn only when there is a choice.
555
+ */
556
+ private renderAgentPicker;
557
+ private renderModePicker;
528
558
  private renderSessionTabs;
529
559
  /** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
530
560
  private renderRoster;
531
561
  private _onSessionTabClick;
532
562
  private _onSessionCreateClick;
533
563
  private renderEmpty;
534
- /** trigger chip 클릭 — textarea 에 trigger 문자 삽입 + focus + popup 자동 활성. */
535
- private insertTrigger;
536
564
  /** 예시 클릭 — 입력창에 채우고 포커스 (사용자가 수정 후 전송 가능) */
537
565
  private useExample;
538
566
  private renderInlineExamples;