@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
@@ -0,0 +1,123 @@
1
+ /*
2
+ * What this guards: **the dock sends the agent a person picked while the domain still offers it, and every action on
3
+ * the settings card saves the domain's agents as one whole list without pinning what was inherited**
4
+ * (design/ai/agent-settings.md §5, §7).
5
+ */
6
+ import { offersAgentPicker, pickAgentId } from '../client/utils/agent-choice'
7
+ import { draftOf, groupByOwner, saveInput, type AgentSettings, type AgentView } from '../client/settings/agent-settings-model'
8
+ import { periodRange } from '../client/settings/usage-model'
9
+
10
+ const choice = (id: string, isDefault = false) => ({ id, name: id, provider: 'openai', model: 'gpt-4o', isDefault })
11
+
12
+ describe('the dock\'s agent', () => {
13
+ it('★ the remembered agent while the domain still offers it, else the default, else the first', () => {
14
+ const choices = [choice('a'), choice('b', true), choice('c')]
15
+ expect(pickAgentId(choices, 'c')).toBe('c')
16
+ expect(pickAgentId(choices, 'gone')).toBe('b')
17
+ expect(pickAgentId([choice('a'), choice('c')], undefined)).toBe('a')
18
+ expect(pickAgentId([], 'a')).toBeUndefined()
19
+ })
20
+
21
+ it('★ no picker for one agent or none', () => {
22
+ expect(offersAgentPicker([])).toBe(false)
23
+ expect(offersAgentPicker([choice('a')])).toBe(false)
24
+ expect(offersAgentPicker([choice('a'), choice('b')])).toBe(true)
25
+ })
26
+ })
27
+
28
+ const domain = (id: string, name: string) => ({ id, name })
29
+ const plant = domain('plant', '서울공장')
30
+ const hq = domain('hq', '본사')
31
+ const group = domain('group', '그룹')
32
+
33
+ function agent(id: string, owner: { id: string; name: string }, extra: Partial<AgentView> = {}): AgentView {
34
+ return {
35
+ id,
36
+ name: id.toUpperCase(),
37
+ provider: 'openai',
38
+ model: 'gpt-4o',
39
+ baseURL: null,
40
+ organization: null,
41
+ active: true,
42
+ owner,
43
+ inherited: owner.id !== 'plant',
44
+ turnedOff: false,
45
+ keyState: owner.id !== 'plant' ? 'inherited' : 'own',
46
+ usable: true,
47
+ isDefault: false,
48
+ usedBelow: [],
49
+ ...extra
50
+ }
51
+ }
52
+
53
+ const settings = (extra: Partial<AgentSettings> = {}): AgentSettings => ({
54
+ chain: [plant, hq, group],
55
+ agents: [agent('p1', plant), agent('h1', hq), agent('h2', hq, { turnedOff: true }), agent('g1', group)],
56
+ defaultAgentId: 'h1',
57
+ defaultFrom: 'inherited',
58
+ state: 'configured',
59
+ ...extra
60
+ })
61
+
62
+ describe('the card\'s groups', () => {
63
+ it('★ this domain first, then each domain above closest first, named by its place when there are several', () => {
64
+ expect(groupByOwner(settings()).map(one => [one.ownerId, one.inherited, one.path, one.agents.map(a => a.id)])).toEqual([
65
+ ['plant', false, '그룹 › 본사 › 서울공장', ['p1']],
66
+ ['hq', true, '그룹 › 본사', ['h1', 'h2']],
67
+ ['group', true, '그룹', ['g1']]
68
+ ])
69
+ })
70
+
71
+ it('a domain above with no agents has no group; this domain always has one', () => {
72
+ const groups = groupByOwner(settings({ agents: [agent('g1', group)] }))
73
+ expect(groups.map(one => one.ownerId)).toEqual(['plant', 'group'])
74
+ })
75
+ })
76
+
77
+ describe('what the card saves', () => {
78
+ it('★ only this domain\'s own agents are written, and an inherited default is not pinned here', () => {
79
+ const input = saveInput(settings(), { kind: 'active', agentId: 'p1', active: false })
80
+ expect(input.agents).toEqual([{ id: 'p1', name: 'P1', provider: 'openai', model: 'gpt-4o', active: false }])
81
+ expect(input).not.toHaveProperty('defaultAgentId')
82
+ expect(input.turnedOff).toEqual(['h2'])
83
+ })
84
+
85
+ it('★ picking a default writes this domain\'s own choice', () => {
86
+ expect(saveInput(settings(), { kind: 'default', agentId: 'g1' })).toMatchObject({ defaultAgentId: 'g1' })
87
+ })
88
+
89
+ it('★ turning an inherited agent off here adds it to this domain\'s list; on again takes it out', () => {
90
+ expect(saveInput(settings(), { kind: 'turn-off', agentId: 'h1', off: true }).turnedOff).toEqual(['h2', 'h1'])
91
+ expect(saveInput(settings(), { kind: 'turn-off', agentId: 'h2', off: false }).turnedOff).toEqual([])
92
+ })
93
+
94
+ it('★ a new agent is appended with its key; an edit keeps the stored key when none was typed', () => {
95
+ const added = saveInput(settings(), { kind: 'upsert', draft: { ...draftOf(), name: ' New ', provider: 'gemini', model: 'gemini-x', apiKey: ' k ' } })
96
+ expect(added.agents.at(-1)).toEqual({ name: 'New', provider: 'gemini', model: 'gemini-x', active: true, apiKey: 'k' })
97
+
98
+ const edited = saveInput(settings(), { kind: 'upsert', draft: { ...draftOf(agent('p1', plant)), model: 'gpt-4o-mini' } })
99
+ expect(edited.agents).toEqual([{ id: 'p1', name: 'P1', provider: 'openai', model: 'gpt-4o-mini', active: true }])
100
+ })
101
+
102
+ it('an organization is only an OpenAI field', () => {
103
+ const input = saveInput(settings(), { kind: 'upsert', draft: { ...draftOf(), name: 'C', provider: 'anthropic', model: 'c', organization: 'org-x' } })
104
+ expect(input.agents.at(-1)).not.toHaveProperty('organization')
105
+ })
106
+
107
+ it('★ deleting the agent that was this domain\'s default drops the default with it', () => {
108
+ const own = settings({ defaultAgentId: 'p1', defaultFrom: 'own' })
109
+ expect(saveInput(own, { kind: 'delete', agentId: 'p1' })).toEqual({ agents: [], turnedOff: ['h2'] })
110
+ })
111
+
112
+ it('★ an inherited agent answered with this domain\'s key, and given back', () => {
113
+ expect(saveInput(settings(), { kind: 'key-override', agentId: 'h1', apiKey: 'k' }).inheritedKeys).toEqual([{ agentId: 'h1', apiKey: 'k' }])
114
+ expect(saveInput(settings(), { kind: 'key-clear', agentId: 'h1' }).inheritedKeys).toEqual([{ agentId: 'h1', clear: true }])
115
+ })
116
+ })
117
+
118
+ it('a period runs from the start of its first day to the end of the hour in progress', () => {
119
+ const now = new Date(2026, 9, 12, 14, 25)
120
+ expect(periodRange('today', now)).toEqual({ from: new Date(2026, 9, 12, 0, 0), to: new Date(2026, 9, 12, 15, 0) })
121
+ expect(periodRange('7d', now).from).toEqual(new Date(2026, 9, 6, 0, 0))
122
+ expect(periodRange('30d', now).from).toEqual(new Date(2026, 8, 13, 0, 0))
123
+ })
@@ -120,7 +120,8 @@ describe('attachments in the chat follow the mode', () => {
120
120
  assert.match(chat, /mutation: ATTACH_TO_CONVERSATION_MUTATION/)
121
121
  /* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
122
122
  assert.match(chat, /context: \{ hasUpload: true \}/)
123
- assert.match(chat, /attachmentIds\s*\n\s*\}\)/)
123
+ /* Sent with the turn's input - the agent the person picked follows it (design/ai/agent-settings.md §5). */
124
+ assert.match(chat, /\n\s*attachmentIds,\s*\n\s*agentId: this\.agentId\s*\n\s*\}\)/)
124
125
  })
125
126
 
126
127
  it('a stored file opens through the conversation route', () => {
@@ -130,7 +131,7 @@ describe('attachments in the chat follow the mode', () => {
130
131
 
131
132
  describe('what twin needed of the dock', () => {
132
133
  it('a surface that says its words changed repaints the header without reopening the conversation', () => {
133
- assert.match(dock, /transition === 'refresh'\) \{\s*this\.surface = next\s*this\.screenChanged = false\s*this\.requestUpdate\(\)\s*return/)
134
+ assert.match(dock, /transition === 'refresh'\) \{\s*this\.surface = next\s*this\.requestUpdate\(\)\s*return/)
134
135
  })
135
136
 
136
137
  it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
@@ -62,7 +62,7 @@ describe('nothing irreversible without asking', () => {
62
62
  assert.match(manager, /import \{ OxPrompt \} from '@operato\/popup'/)
63
63
  assert.match(manager, /this\.askRemove\(entry, name\)/)
64
64
  assert.match(manager, /this\.askOpen\(entry, name\)/)
65
- assert.match(manager, /@click=\$\{\(\) => this\.setVisibility\(entry, 'private'\)\}/)
65
+ assert.match(manager, /@select=\$\{\(\) => this\.setVisibility\(entry, 'private'\)\}/)
66
66
  })
67
67
 
68
68
  it('no browser dialog - they sit outside the theme', () => {
@@ -79,10 +79,16 @@ describe('nothing irreversible without asking', () => {
79
79
  })
80
80
  })
81
81
 
82
- describe('the filters (shared dock §2-6)', () => {
83
- it('this kind by default, every kind on request; whose, visibility, unread, hidden', () => {
84
- assert.match(manager, /kind: this\.allKinds \? '' : this\.kind/)
82
+ describe('finding a conversation (shared dock §2-6, 2026-10-12)', () => {
83
+ it('★ this screen\'s conversations; the search and the filters stand open together - no "Filters" button', () => {
84
+ assert.match(manager, /kind: this\.kind,/)
85
85
  for (const filter of ['whose', 'visibility', 'includeHidden', 'unreadOnly']) assert.match(manager, new RegExp(`${filter}: this\\.${filter}`))
86
+ assert.doesNotMatch(manager, /filter-toggle|filtersOpen|allKinds/)
87
+ })
88
+
89
+ it('★ a row\'s actions open in the platform\'s popup menu - the hand-made one stayed open and stacked', () => {
90
+ assert.match(manager, /OxPopupMenu\.open\(/)
91
+ assert.doesNotMatch(manager, /<details class="actions"/)
86
92
  })
87
93
 
88
94
  it('a changed filter starts from the first page', () => {
@@ -33,3 +33,11 @@ test('legacy Board chat forwards shared controls through existing host events',
33
33
  assert.match(chat, /new CustomEvent\('session-switch'/)
34
34
  assert.match(chat, /new CustomEvent\('session-create'/)
35
35
  })
36
+ test('★ a conversation not yet started offers no second new one - the name and the button said 새 대화 twice (2026-10-12)', () => {
37
+ assert.match(toolbar, /\$\{this\.sessionId \? html`<button class="session-new"/)
38
+ })
39
+ test('★ the empty chat holds what the screen offers, not what the product boasts - badges and the trigger box are gone (2026-10-12)', () => {
40
+ const chat = read('../client/components/assistant-chat.ts')
41
+ const empty = chat.slice(chat.indexOf('private renderEmpty()'), chat.indexOf('/** 예시 클릭'))
42
+ assert.doesNotMatch(empty, /class="badge"|trigger-row/)
43
+ })
@@ -5,7 +5,7 @@
5
5
  import { readFileSync } from 'fs'
6
6
  import { join } from 'path'
7
7
 
8
- import { appendWords, composerHeight, composerKeyAction, sendShortcut, tableAsWords } from '../client/utils/composer-frame'
8
+ import { appendWords, composerHeight, composerKeyAction, composerSuggestion, sendShortcut, tableAsWords } from '../client/utils/composer-frame'
9
9
 
10
10
  const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
11
11
 
@@ -62,6 +62,20 @@ describe('Enter', () => {
62
62
  expect(composerKeyAction({ ...enter, altKey: true }, false)).toBeNull()
63
63
  })
64
64
 
65
+ it('★ a plain Tab in an empty box takes the screen\'s suggestion - what the screen already said is not typed again', () => {
66
+ const tab = { key: 'Tab' }
67
+ expect(composerKeyAction(tab, false, { empty: true, suggestion: '이 모델의 특징을 설명해 줘' })).toBe('take-suggestion')
68
+ expect(composerKeyAction(tab, true, { empty: true, suggestion: '이 모델의 특징을 설명해 줘' })).toBe('take-suggestion')
69
+ })
70
+
71
+ it('★ Tab is the page\'s again once anything is written, with no suggestion, or with a modifier', () => {
72
+ const tab = { key: 'Tab' }
73
+ expect(composerKeyAction(tab, false, { empty: false, suggestion: 'x' })).toBeNull()
74
+ expect(composerKeyAction(tab, false, { empty: true })).toBeNull()
75
+ expect(composerKeyAction(tab, false, { empty: true, suggestion: ' ' })).toBeNull()
76
+ expect(composerKeyAction({ ...tab, shiftKey: true }, false, { empty: true, suggestion: 'x' })).toBeNull()
77
+ })
78
+
65
79
  it('the shortcut is named in this machine\'s keys', () => {
66
80
  expect(sendShortcut('MacIntel')).toBe('⌘ Enter')
67
81
  expect(sendShortcut('Win32')).toBe('Ctrl+Enter')
@@ -69,7 +83,31 @@ describe('Enter', () => {
69
83
  })
70
84
 
71
85
  it('★ the chat decides the key by the rule', () => {
72
- expect(chat).toMatch(/composerKeyAction\(e, this\.composerWide\)/)
86
+ expect(chat).toMatch(/composerKeyAction\(e, this\.composerWide, \{ empty: !this\.input, suggestion: this\.suggestion \}\)/)
87
+ })
88
+ })
89
+
90
+ describe('what a Tab writes in', () => {
91
+ const mode = [{ label: '고치기 · 살펴보기', items: ['현재 Figure의 재질 그룹을 두 개로 줄여줘', '이 Figure는 왜 발행이 안 돼?'] }]
92
+ const host = [{ label: '만들기', items: ['모니터링 대시보드 만들어 줘'] }]
93
+
94
+ it('★ the empty conversation suggests its own first example request - a request, which the placeholder is not', () => {
95
+ expect(composerSuggestion({ started: false, modeExamples: mode, hostExamples: host })).toBe('현재 Figure의 재질 그룹을 두 개로 줄여줘')
96
+ })
97
+
98
+ it('a conversation with no examples of its own suggests the host\'s', () => {
99
+ expect(composerSuggestion({ started: false, modeExamples: [], hostExamples: host })).toBe('모니터링 대시보드 만들어 줘')
100
+ expect(composerSuggestion({ started: false, modeExamples: [{ items: [] }, ...mode] })).toBe('현재 Figure의 재질 그룹을 두 개로 줄여줘')
101
+ })
102
+
103
+ it('★ nothing is suggested once the conversation is under way, or when no one gave an example', () => {
104
+ expect(composerSuggestion({ started: true, modeExamples: mode, hostExamples: host })).toBeUndefined()
105
+ expect(composerSuggestion({ started: false })).toBeUndefined()
106
+ })
107
+
108
+ it('★ the box shows the suggestion first and falls back to how to write', () => {
109
+ expect(chat).toMatch(/placeholder=\$\{this\.suggestion \?\? this\.conversation\.placeholder \?\? this\.placeholder \?\? i18next\.t\('ai-assistant\.text\.input-placeholder'\)\}/)
110
+ expect(chat).toMatch(/composerSuggestion\(\{ started: this\.lines\.length > 0, modeExamples: this\.modeView\?\.examples, hostExamples: this\.examples \}\)/)
73
111
  })
74
112
  })
75
113
 
@@ -0,0 +1,33 @@
1
+ /*
2
+ * What this guards: the dock takes its room from the left menu and gives it back - without overriding a menu the
3
+ * person folded, and without saving its own fold as the person's choice.
4
+ */
5
+ import { foldForDock, unfoldAfterDock } from '../client/utils/menu-room'
6
+
7
+ it('★ an open menu folds when the dock opens and opens again when it closes', () => {
8
+ const menu = { collapsed: false }
9
+ const folded = foldForDock(menu)
10
+ expect([folded, menu.collapsed]).toEqual([true, true])
11
+ unfoldAfterDock(menu, folded)
12
+ expect(menu.collapsed).toBe(false)
13
+ })
14
+
15
+ it('★ a menu the person had folded stays folded after the dock closes', () => {
16
+ const menu = { collapsed: true }
17
+ const folded = foldForDock(menu)
18
+ unfoldAfterDock(menu, folded)
19
+ expect([folded, menu.collapsed]).toEqual([false, true])
20
+ })
21
+
22
+ it('a menu the person opened while the dock was open is left open', () => {
23
+ const menu = { collapsed: false }
24
+ const folded = foldForDock(menu)
25
+ menu.collapsed = false
26
+ unfoldAfterDock(menu, folded)
27
+ expect(menu.collapsed).toBe(false)
28
+ })
29
+
30
+ it('no menu on the page is nothing to do', () => {
31
+ expect(foldForDock(undefined)).toBe(false)
32
+ expect(() => unfoldAfterDock(undefined, true)).not.toThrow()
33
+ })
@@ -143,11 +143,13 @@ describe('what the walk itself has to be true for', () => {
143
143
  * 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
144
144
  * 2026-09-28: 29 — requestProposal (a proposal is requested once, ADR-0097 Q-D4), declared.
145
145
  * 2026-09-30: 30 — applyEntryProposal (enter the rows the person pressed, design/ai/data-entry.md), declared.
146
+ * 2026-10-12: 35 — aiAgentSettings · aiAgentChoices · aiProviderModels · saveAiAgents · aiUsageSummary (a domain's AI
147
+ * agents and their usage, design/ai/agent-settings.md), declared.
146
148
  */
147
149
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
148
150
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
149
151
 
150
- expect(all).toBe(30)
152
+ expect(all).toBe(35)
151
153
  expect(open).toBe(2)
152
154
  })
153
155
  })
@@ -28,15 +28,16 @@ describe('returning to work by its subject', () => {
28
28
 
29
29
  describe('discoverable conversation controls', () => {
30
30
  const manager = readFileSync(join(__dirname, '../client/components/assistant-session-manager.ts'), 'utf8')
31
- it('uses a native button to open a conversation and a visible disclosure to manage it', () => {
31
+ it('uses a native button to open a conversation and a visible button to manage it in the platform menu', () => {
32
32
  expect(manager).toContain('<button class="open-row"')
33
- expect(manager).toContain('<summary aria-label=')
34
- expect(manager).not.toContain('.row:hover .acts')
33
+ expect(manager).toContain('class="more-menu"')
34
+ expect(manager).toContain('OxPopupMenu.open(')
35
35
  })
36
- it('distinguishes filtered emptiness, supplies reset, and folds advanced filters', () => {
36
+ it('distinguishes filtered emptiness, supplies one clear, and keeps the filters open', () => {
37
37
  expect(manager).toContain("t('filtered-empty'")
38
- expect(manager).toContain('this.resetFilters()')
39
- expect(manager).toContain('?hidden=${!this.filtersOpen}')
38
+ expect(manager).toContain('this.clearFilters()')
39
+ expect(manager).toContain('<div class="filters">')
40
+ expect(manager).not.toContain('filtersOpen')
40
41
  expect(manager).toContain('e.stopPropagation(); this.onSearch')
41
42
  })
42
43
  })
@@ -1,6 +1,8 @@
1
+ import bootstrap from './dist-client/bootstrap.js'
1
2
  import route from './dist-client/route.js'
2
3
 
3
4
  export default {
5
+ bootstrap,
4
6
  route,
5
7
  routes: [{ tagname: 'assistant-workspace-page', page: 'assistant-workspace' }]
6
8
  }
@@ -13,7 +13,7 @@
13
13
  "ai-assistant.session.filters": "Filters",
14
14
  "ai-assistant.session.reset-filters": "Clear search and filters",
15
15
  "ai-assistant.session.filtered-empty": "No conversations match these filters here.",
16
- "ai-assistant.session.scope-empty": "No conversations for this subject yet.",
16
+ "ai-assistant.session.scope-empty": "No conversations yet.",
17
17
  "ai-assistant.session.audience-private": "Participants only",
18
18
  "ai-assistant.session.audience-open": "Everyone in this domain",
19
19
  "ai-assistant.session.manage": "Manage conversation",
@@ -31,16 +31,9 @@
31
31
  "ai-assistant.label.create": "Create",
32
32
  "ai-assistant.label.edit": "Edit",
33
33
  "ai-assistant.label.style": "Style",
34
- "ai-assistant.label.korean-supported": "Korean / English",
35
- "ai-assistant.label.multi-command": "Batch commands",
36
- "ai-assistant.label.review-able": "Reviewable",
37
34
  "ai-assistant.text.empty-title": "Design a board together with AI",
38
35
  "ai-assistant.text.empty-subtitle": "Describe in natural language and AI will create, edit, and style the board. Click an example below to begin.",
39
36
  "ai-assistant.text.input-placeholder": "Describe what you want — # component · @ catalog · / template autocomplete",
40
- "ai-assistant.text.triggers-heading": "Or jump in with autocomplete",
41
- "ai-assistant.text.trigger-component-tooltip": "Mention a component on the board",
42
- "ai-assistant.text.trigger-user-tooltip": "Mention a domain teammate",
43
- "ai-assistant.text.trigger-insert-tooltip": "Insert prefab / palette / layout / command template",
44
37
  "ai-assistant.text.send-tooltip": "Send (Enter)",
45
38
  "ai-assistant.text.show-examples-tooltip": "Show common command examples",
46
39
  "ai-assistant.text.applied-changes": "Applied.",
@@ -54,9 +47,6 @@
54
47
  "ai-assistant.example.style-dark-mode": "Dark mode — deep navy background, white text, teal accents",
55
48
  "ai-assistant.example.style-rounded-shadow": "Round all shape corners and add a soft card shadow",
56
49
  "ai-assistant.button.new-chat": "New chat",
57
- "ai-assistant.label.trigger-component": "Component",
58
- "ai-assistant.label.trigger-user": "User",
59
- "ai-assistant.label.trigger-insert": "Insert",
60
50
  "ai-assistant.button.examples": "Examples",
61
51
  "ai-assistant.button.close": "Close",
62
52
  "ai-assistant.button.revert": "Revert",
@@ -317,5 +307,81 @@
317
307
  "ai-assistant.text.room-attachments-unavailable": "Attachments to participants are not supported yet. Remove the attachments before sending.",
318
308
  "ai-assistant.button.reply": "Reply",
319
309
  "ai-assistant.button.cancel-reply": "Cancel reply",
320
- "ai-assistant.text.reply-to": "Reply to {name}"
310
+ "ai-assistant.text.reply-to": "Reply to {name}",
311
+ "ai-assistant.label.ai-agent": "AI agent",
312
+ "ai-assistant.agent.title": "AI agents",
313
+ "ai-assistant.agent.load-failed": "Could not read the AI agents.",
314
+ "ai-assistant.agent.retry": "Try again",
315
+ "ai-assistant.agent.loading": "Reading…",
316
+ "ai-assistant.agent.add": "Agent",
317
+ "ai-assistant.agent.inherited-from": "Inherited from {path}",
318
+ "ai-assistant.agent.this-domain": "This domain",
319
+ "ai-assistant.agent.status-on": "AI is on here · {count} agents to pick · default {name}",
320
+ "ai-assistant.agent.status-no-key": "No agent has an API key, so AI is off here.",
321
+ "ai-assistant.agent.status-all-off": "Every agent is turned off, so AI is off here.",
322
+ "ai-assistant.agent.status-no-agent": "There is no AI agent, so AI is off here.",
323
+ "ai-assistant.agent.default": "Default",
324
+ "ai-assistant.agent.default-hint": "The agent a person gets before picking one",
325
+ "ai-assistant.agent.use": "Use",
326
+ "ai-assistant.agent.use-here": "Use here",
327
+ "ai-assistant.agent.used-below": "{count} below",
328
+ "ai-assistant.agent.key-this-domain": "This domain's key {hint}",
329
+ "ai-assistant.agent.key-of": "{domain} key",
330
+ "ai-assistant.agent.key-missing": "No key",
331
+ "ai-assistant.agent.more": "More",
332
+ "ai-assistant.agent.edit": "Edit",
333
+ "ai-assistant.agent.delete": "Delete",
334
+ "ai-assistant.agent.replace-own-key": "Replace this domain's key",
335
+ "ai-assistant.agent.use-own-key": "Use this domain's key",
336
+ "ai-assistant.agent.back-to-key": "Back to the {domain} key",
337
+ "ai-assistant.agent.key-placeholder": "API key of this domain",
338
+ "ai-assistant.agent.cancel": "Cancel",
339
+ "ai-assistant.agent.save": "Save",
340
+ "ai-assistant.agent.name": "Name",
341
+ "ai-assistant.agent.provider": "Provider",
342
+ "ai-assistant.agent.api-key": "API key",
343
+ "ai-assistant.agent.key-stored": "Stored key {hint} - enter only to change it",
344
+ "ai-assistant.agent.model": "Model",
345
+ "ai-assistant.agent.pick-model": "Pick a model",
346
+ "ai-assistant.agent.reload-models": "Read the models again",
347
+ "ai-assistant.agent.enter-key-first": "Enter the API key to pick a model.",
348
+ "ai-assistant.agent.reading-models": "Reading the provider's models…",
349
+ "ai-assistant.agent.model-gone": "The provider no longer offers this model. Pick another.",
350
+ "ai-assistant.agent.advanced": "Advanced",
351
+ "ai-assistant.agent.base-url": "Base URL",
352
+ "ai-assistant.agent.organization": "Organization",
353
+ "ai-assistant.agent.back-to-key-title": "Go back to the {domain} key?",
354
+ "ai-assistant.agent.back-to-key-text": "This domain's key for \"{name}\" is deleted, and the key of {domain} answers again.",
355
+ "ai-assistant.agent.turn-off-title": "Turn \"{name}\" off?",
356
+ "ai-assistant.agent.turn-off-text": "It stops answering here and in {domains}.",
357
+ "ai-assistant.agent.delete-below": "It also stops answering in {domains}.",
358
+ "ai-assistant.agent.delete-title": "Delete \"{name}\"?",
359
+ "ai-assistant.agent.delete-text": "Its API key is deleted with it. Usage already recorded stays.",
360
+ "ai-assistant.agent.used-below-title": "Domains below using \"{name}\"",
361
+ "ai-assistant.agent.close": "Close",
362
+ "ai-assistant.agent.save-failed": "Not saved",
363
+ "ai-assistant.agent.confirm": "Confirm",
364
+ "ai-assistant.usage.title": "AI usage",
365
+ "ai-assistant.usage.deleted-agent": "(deleted agent)",
366
+ "ai-assistant.usage.today": "Today",
367
+ "ai-assistant.usage.7d": "7 days",
368
+ "ai-assistant.usage.30d": "30 days",
369
+ "ai-assistant.usage.by-hour": "Hour",
370
+ "ai-assistant.usage.by-day": "Day",
371
+ "ai-assistant.usage.by-month": "Month",
372
+ "ai-assistant.usage.agent": "Agent",
373
+ "ai-assistant.usage.all-agents": "All agents",
374
+ "ai-assistant.usage.person": "Person",
375
+ "ai-assistant.usage.all-people": "Everyone",
376
+ "ai-assistant.usage.include-below": "Domains below",
377
+ "ai-assistant.usage.load-failed": "Could not read the usage.",
378
+ "ai-assistant.usage.loading": "Reading…",
379
+ "ai-assistant.usage.empty": "No AI was used in this period.",
380
+ "ai-assistant.usage.when": "When",
381
+ "ai-assistant.usage.calls": "Calls",
382
+ "ai-assistant.usage.input-tokens": "Input tokens",
383
+ "ai-assistant.usage.output-tokens": "Output tokens",
384
+ "ai-assistant.usage.failures": "Failed",
385
+ "ai-assistant.usage.total": "Total",
386
+ "ai-assistant.text.send-to-people-notice": "This goes to the people in this conversation. The AI does not answer it."
321
387
  }
@@ -13,7 +13,7 @@
13
13
  "ai-assistant.session.filters": "Filtros",
14
14
  "ai-assistant.session.reset-filters": "Borrar búsqueda y filtros",
15
15
  "ai-assistant.session.filtered-empty": "Ninguna conversación coincide aquí con estos filtros.",
16
- "ai-assistant.session.scope-empty": "Aún no hay conversaciones sobre este tema.",
16
+ "ai-assistant.session.scope-empty": "Aún no hay conversaciones.",
17
17
  "ai-assistant.session.audience-private": "Solo participantes",
18
18
  "ai-assistant.session.audience-open": "Todos en este dominio",
19
19
  "ai-assistant.session.manage": "Gestionar conversación",
@@ -31,16 +31,9 @@
31
31
  "ai-assistant.label.create": "Crear",
32
32
  "ai-assistant.label.edit": "Editar",
33
33
  "ai-assistant.label.style": "Estilo",
34
- "ai-assistant.label.korean-supported": "Coreano / inglés",
35
- "ai-assistant.label.multi-command": "Comandos por lotes",
36
- "ai-assistant.label.review-able": "Revisable",
37
34
  "ai-assistant.text.empty-title": "Diseñe un tablero junto con la IA",
38
35
  "ai-assistant.text.empty-subtitle": "Descríbalo en lenguaje natural y la IA creará, editará y aplicará estilo al tablero. Haga clic en un ejemplo de abajo para empezar.",
39
36
  "ai-assistant.text.input-placeholder": "Describa lo que desea — # componente · @ catálogo · / autocompletar plantilla",
40
- "ai-assistant.text.triggers-heading": "O empiece con el autocompletado",
41
- "ai-assistant.text.trigger-component-tooltip": "Mencionar un componente del tablero",
42
- "ai-assistant.text.trigger-user-tooltip": "Mencionar a un compañero del dominio",
43
- "ai-assistant.text.trigger-insert-tooltip": "Insertar plantilla de prefab / paleta / diseño / comando",
44
37
  "ai-assistant.text.send-tooltip": "Enviar (Enter)",
45
38
  "ai-assistant.text.show-examples-tooltip": "Mostrar ejemplos de comandos habituales",
46
39
  "ai-assistant.text.applied-changes": "Aplicado.",
@@ -54,9 +47,6 @@
54
47
  "ai-assistant.example.style-dark-mode": "Modo oscuro — fondo azul marino oscuro, texto blanco, acentos verde azulado",
55
48
  "ai-assistant.example.style-rounded-shadow": "Redondear las esquinas de todas las formas y añadir una sombra de tarjeta suave",
56
49
  "ai-assistant.button.new-chat": "Nuevo chat",
57
- "ai-assistant.label.trigger-component": "Componente",
58
- "ai-assistant.label.trigger-user": "Usuario",
59
- "ai-assistant.label.trigger-insert": "Insertar",
60
50
  "ai-assistant.button.examples": "Ejemplos",
61
51
  "ai-assistant.button.close": "Cerrar",
62
52
  "ai-assistant.button.revert": "Revertir",
@@ -317,5 +307,81 @@
317
307
  "ai-assistant.text.room-attachments-unavailable": "Aún no se admite enviar archivos adjuntos a los participantes. Quite los archivos adjuntos antes de enviar.",
318
308
  "ai-assistant.button.reply": "Responder",
319
309
  "ai-assistant.button.cancel-reply": "Cancelar respuesta",
320
- "ai-assistant.text.reply-to": "Responder a {name}"
310
+ "ai-assistant.text.reply-to": "Responder a {name}",
311
+ "ai-assistant.label.ai-agent": "Agente de IA",
312
+ "ai-assistant.agent.title": "Agentes de IA",
313
+ "ai-assistant.agent.load-failed": "No se pudieron leer los agentes de IA.",
314
+ "ai-assistant.agent.retry": "Reintentar",
315
+ "ai-assistant.agent.loading": "Leyendo…",
316
+ "ai-assistant.agent.add": "Agente",
317
+ "ai-assistant.agent.inherited-from": "Heredado de {path}",
318
+ "ai-assistant.agent.this-domain": "Este dominio",
319
+ "ai-assistant.agent.status-on": "La IA está activa aquí · {count} agentes para elegir · predeterminado {name}",
320
+ "ai-assistant.agent.status-no-key": "Ningún agente tiene clave de API, así que la IA no está activa aquí.",
321
+ "ai-assistant.agent.status-all-off": "Todos los agentes están desactivados, así que la IA no está activa aquí.",
322
+ "ai-assistant.agent.status-no-agent": "No hay ningún agente de IA, así que la IA no está activa aquí.",
323
+ "ai-assistant.agent.default": "Predeterminado",
324
+ "ai-assistant.agent.default-hint": "El agente que se usa antes de elegir uno",
325
+ "ai-assistant.agent.use": "Usar",
326
+ "ai-assistant.agent.use-here": "Usar aquí",
327
+ "ai-assistant.agent.used-below": "{count} por debajo",
328
+ "ai-assistant.agent.key-this-domain": "Clave de este dominio {hint}",
329
+ "ai-assistant.agent.key-of": "Clave de {domain}",
330
+ "ai-assistant.agent.key-missing": "Sin clave",
331
+ "ai-assistant.agent.more": "Más",
332
+ "ai-assistant.agent.edit": "Editar",
333
+ "ai-assistant.agent.delete": "Eliminar",
334
+ "ai-assistant.agent.replace-own-key": "Cambiar la clave de este dominio",
335
+ "ai-assistant.agent.use-own-key": "Usar la clave de este dominio",
336
+ "ai-assistant.agent.back-to-key": "Volver a la clave de {domain}",
337
+ "ai-assistant.agent.key-placeholder": "Clave de API de este dominio",
338
+ "ai-assistant.agent.cancel": "Cancelar",
339
+ "ai-assistant.agent.save": "Guardar",
340
+ "ai-assistant.agent.name": "Nombre",
341
+ "ai-assistant.agent.provider": "Proveedor",
342
+ "ai-assistant.agent.api-key": "Clave de API",
343
+ "ai-assistant.agent.key-stored": "Clave guardada {hint}: introdúzcala solo para cambiarla",
344
+ "ai-assistant.agent.model": "Modelo",
345
+ "ai-assistant.agent.pick-model": "Elija un modelo",
346
+ "ai-assistant.agent.reload-models": "Volver a leer los modelos",
347
+ "ai-assistant.agent.enter-key-first": "Introduzca la clave de API para elegir un modelo.",
348
+ "ai-assistant.agent.reading-models": "Leyendo los modelos del proveedor…",
349
+ "ai-assistant.agent.model-gone": "El proveedor ya no ofrece este modelo. Elija otro.",
350
+ "ai-assistant.agent.advanced": "Avanzado",
351
+ "ai-assistant.agent.base-url": "URL base",
352
+ "ai-assistant.agent.organization": "Organización",
353
+ "ai-assistant.agent.back-to-key-title": "¿Volver a la clave de {domain}?",
354
+ "ai-assistant.agent.back-to-key-text": "Se elimina la clave de este dominio para \"{name}\" y vuelve a responder la clave de {domain}.",
355
+ "ai-assistant.agent.turn-off-title": "¿Desactivar \"{name}\"?",
356
+ "ai-assistant.agent.turn-off-text": "Dejará de responder aquí y en {domains}.",
357
+ "ai-assistant.agent.delete-below": "También dejará de responder en {domains}.",
358
+ "ai-assistant.agent.delete-title": "¿Eliminar \"{name}\"?",
359
+ "ai-assistant.agent.delete-text": "Su clave de API se elimina con él. El uso ya registrado se conserva.",
360
+ "ai-assistant.agent.used-below-title": "Dominios inferiores que usan \"{name}\"",
361
+ "ai-assistant.agent.close": "Cerrar",
362
+ "ai-assistant.agent.save-failed": "No se guardó",
363
+ "ai-assistant.agent.confirm": "Confirmar",
364
+ "ai-assistant.usage.title": "Uso de IA",
365
+ "ai-assistant.usage.deleted-agent": "(agente eliminado)",
366
+ "ai-assistant.usage.today": "Hoy",
367
+ "ai-assistant.usage.7d": "7 días",
368
+ "ai-assistant.usage.30d": "30 días",
369
+ "ai-assistant.usage.by-hour": "Hora",
370
+ "ai-assistant.usage.by-day": "Día",
371
+ "ai-assistant.usage.by-month": "Mes",
372
+ "ai-assistant.usage.agent": "Agente",
373
+ "ai-assistant.usage.all-agents": "Todos los agentes",
374
+ "ai-assistant.usage.person": "Persona",
375
+ "ai-assistant.usage.all-people": "Todos",
376
+ "ai-assistant.usage.include-below": "Incluir dominios inferiores",
377
+ "ai-assistant.usage.load-failed": "No se pudo leer el uso.",
378
+ "ai-assistant.usage.loading": "Leyendo…",
379
+ "ai-assistant.usage.empty": "No se usó IA en este periodo.",
380
+ "ai-assistant.usage.when": "Cuándo",
381
+ "ai-assistant.usage.calls": "Llamadas",
382
+ "ai-assistant.usage.input-tokens": "Tokens de entrada",
383
+ "ai-assistant.usage.output-tokens": "Tokens de salida",
384
+ "ai-assistant.usage.failures": "Fallidas",
385
+ "ai-assistant.usage.total": "Total",
386
+ "ai-assistant.text.send-to-people-notice": "Esto va a los participantes de esta conversación. La IA no responde."
321
387
  }