@things-factory/ai-assistant 10.1.79 → 10.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/client/components/assistant-chat.ts +102 -13
  2. package/client/components/assistant-dock.ts +206 -18
  3. package/client/components/assistant-mode.test.ts +16 -2
  4. package/client/components/assistant-session-manager.ts +58 -26
  5. package/client/components/assistant-session-toolbar.ts +4 -2
  6. package/client/components/chat-echo-dedup.ts +4 -0
  7. package/client/components/proposal-outcome-line.ts +36 -0
  8. package/client/components/proposal-request.test.ts +52 -0
  9. package/client/utils/assistant-navigation.test.ts +38 -0
  10. package/client/utils/assistant-navigation.ts +6 -0
  11. package/client/utils/assistant-session-controller.ts +2 -0
  12. package/client/utils/assistant-session-transport.ts +2 -2
  13. package/client/utils/assistant-surface.ts +21 -0
  14. package/dist-client/components/assistant-chat.d.ts +15 -0
  15. package/dist-client/components/assistant-chat.js +106 -13
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.d.ts +16 -0
  18. package/dist-client/components/assistant-dock.js +221 -20
  19. package/dist-client/components/assistant-dock.js.map +1 -1
  20. package/dist-client/components/assistant-mode.test.js +14 -2
  21. package/dist-client/components/assistant-mode.test.js.map +1 -1
  22. package/dist-client/components/assistant-session-manager.d.ts +4 -0
  23. package/dist-client/components/assistant-session-manager.js +74 -27
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  26. package/dist-client/components/assistant-session-toolbar.js +8 -2
  27. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  28. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  29. package/dist-client/components/chat-echo-dedup.js +2 -0
  30. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  31. package/dist-client/components/proposal-outcome-line.d.ts +29 -0
  32. package/dist-client/components/proposal-outcome-line.js +15 -0
  33. package/dist-client/components/proposal-outcome-line.js.map +1 -1
  34. package/dist-client/components/proposal-request.test.d.ts +1 -0
  35. package/dist-client/components/proposal-request.test.js +45 -0
  36. package/dist-client/components/proposal-request.test.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-client/utils/assistant-navigation.d.ts +6 -0
  39. package/dist-client/utils/assistant-navigation.js +9 -0
  40. package/dist-client/utils/assistant-navigation.js.map +1 -0
  41. package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
  42. package/dist-client/utils/assistant-navigation.test.js +37 -0
  43. package/dist-client/utils/assistant-navigation.test.js.map +1 -0
  44. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  45. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  46. package/dist-client/utils/assistant-session-transport.js +2 -2
  47. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  48. package/dist-client/utils/assistant-surface.d.ts +19 -0
  49. package/dist-client/utils/assistant-surface.js.map +1 -1
  50. package/dist-server/service/assistant-chat-resolver.js +1 -0
  51. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  52. package/dist-server/service/chat-message/chat-message.d.ts +10 -0
  53. package/dist-server/service/chat-message/chat-message.js +29 -0
  54. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  55. package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
  56. package/dist-server/service/chat-message/proposal-outcome.js +35 -1
  57. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
  58. package/dist-server/service/chat-session/chat-session.d.ts +5 -5
  59. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  60. package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
  61. package/dist-server/service/chat-session/session-inbox.js +7 -0
  62. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  63. package/dist-server/service/chat-session/session-visibility.d.ts +8 -5
  64. package/dist-server/service/chat-session/session-visibility.js +9 -15
  65. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  66. package/dist-server/service/chat-session-resolver.d.ts +1 -0
  67. package/dist-server/service/chat-session-resolver.js +64 -10
  68. package/dist-server/service/chat-session-resolver.js.map +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/server/service/assistant-chat-resolver.ts +1 -0
  72. package/server/service/chat-message/chat-message.ts +32 -0
  73. package/server/service/chat-message/proposal-outcome.test.ts +53 -2
  74. package/server/service/chat-message/proposal-outcome.ts +33 -0
  75. package/server/service/chat-session/chat-session.ts +5 -5
  76. package/server/service/chat-session/session-inbox.ts +7 -0
  77. package/server/service/chat-session/session-visibility.test.ts +6 -6
  78. package/server/service/chat-session/session-visibility.ts +9 -15
  79. package/server/service/chat-session-resolver.ts +72 -13
  80. package/server/service/session-anchor.test.ts +2 -2
  81. package/test/assistant-dock.test.ts +8 -3
  82. package/test/assistant-session-toolbar.test.ts +7 -1
  83. package/test/open-doors.test.ts +2 -1
  84. package/test/session-discovery.test.ts +42 -0
  85. package/translations/en.json +28 -2
  86. package/translations/ja.json +28 -2
  87. package/translations/ko.json +28 -2
  88. package/translations/ms.json +28 -2
  89. package/translations/zh.json +28 -2
@@ -61,7 +61,7 @@ import {
61
61
  */
62
62
  import { chatDefaults } from './chat-defaults.js'
63
63
  import { isDifferentConversation, resolveConversation, type AssistantMode, type AssistantModeView, pickInitialMode } from './assistant-mode.js'
64
- import { parseProposalOutcomeLine, type ParsedProposalOutcome } from './proposal-outcome-line.js'
64
+ import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState, type ParsedProposalOutcome } from './proposal-outcome-line.js'
65
65
  import { availableTriggers, type TriggerChip } from './chat-triggers.js'
66
66
 
67
67
  interface ChatLine {
@@ -280,6 +280,7 @@ const CHAT_MESSAGES_QUERY = gql`
280
280
  content
281
281
  relatedPatchId
282
282
  toolUsagesJson
283
+ proposalsJson
283
284
  groundingWarningsJson
284
285
  attachmentsJson
285
286
  createdAt
@@ -302,6 +303,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql`
302
303
  content
303
304
  relatedPatchId
304
305
  toolUsagesJson
306
+ proposalsJson
305
307
  groundingWarningsJson
306
308
  attachmentsJson
307
309
  createdAt
@@ -802,6 +804,14 @@ export class OxAssistantChat extends LitElement {
802
804
  @state()
803
805
  private busy = false
804
806
 
807
+ /** Host navigation may retain a conversation for reading while its subject is off screen. */
808
+ @property() sendBlocked = ''
809
+
810
+ /** The host may inspect navigation risk without reading or copying the person's input. */
811
+ get navigationState(): { busy: boolean; draft: boolean } {
812
+ return { busy: this.busy, draft: !!this.input.trim() || this.attachments.length > 0 }
813
+ }
814
+
805
815
  @state()
806
816
  private errorMessage?: string
807
817
 
@@ -2900,6 +2910,8 @@ export class OxAssistantChat extends LitElement {
2900
2910
  const prevSameSender = !!prev && prev.role === 'user' && prev.senderEmail === line.senderEmail
2901
2911
  const showSenderLabel = isOther && !prevSameSender
2902
2912
  const content = (line.content ?? '').trim()
2913
+ /* A "requested" row is the card's state, shown on the card - as a line it would only be the machine's key. */
2914
+ if (line.role === 'system' && parseProposalRequestLine(content)) return nothing
2903
2915
  /*
2904
2916
  * 가려진 줄은 어시스턴트의 말이 아니다 — 마크다운으로 그리지 않고, 복사·재실행 같은 손잡이도
2905
2917
  * 달지 않는다(ADR-0073 판정 1). 그 손잡이들은 있지도 않은 답을 다룬다.
@@ -3145,9 +3157,9 @@ export class OxAssistantChat extends LitElement {
3145
3157
  `
3146
3158
  : nothing}
3147
3159
  <button
3148
- ?disabled=${this.busy || (!this.input.trim() && this.attachments.length === 0)}
3160
+ ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3149
3161
  @click=${this.send}
3150
- title=${i18next.t('ai-assistant.text.send-tooltip')}>
3162
+ title=${this.sendBlocked || i18next.t('ai-assistant.text.send-tooltip')}>
3151
3163
  ${this.busy
3152
3164
  ? html`<span class="spinner"></span>`
3153
3165
  : html`<md-icon>arrow_upward</md-icon>`}
@@ -3930,11 +3942,15 @@ export class OxAssistantChat extends LitElement {
3930
3942
  private renderProposals(line: ChatLine) {
3931
3943
  const items = line.proposals ?? []
3932
3944
  if (items.length === 0) return nothing
3945
+ /* The card's state is the conversation's rows - read once for every card of this line. */
3946
+ const outcomes = this.proposalOutcomes
3947
+ const requested = this.proposalRequests
3933
3948
  return html`
3934
3949
  <div class="proposals">
3935
3950
  ${items.map(p => {
3936
3951
  const key = this.proposalKey(p)
3937
- const sent = this.sentProposals.has(key)
3952
+ const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) })
3953
+ const sent = card.state !== 'ready'
3938
3954
  /*
3939
3955
  Follow-up chips are the host's words, never this component's. The fallback that used
3940
3956
  to live here named a 3D modeller's operations in hardcoded Korean, and it reached the
@@ -3956,14 +3972,23 @@ export class OxAssistantChat extends LitElement {
3956
3972
  the one action the card exists for became unreachable while the model kept
3957
3973
  telling people to press it.
3958
3974
  */ ''}
3959
- <button
3960
- class="run"
3961
- ?disabled=${sent}
3962
- @click=${() => this.executeProposal(p, key)}>
3963
- ${sent
3964
- ? this.proposalStateText(key)
3965
- : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
3966
- </button>
3975
+ ${card.state === 'unconfirmed'
3976
+ ? html`
3977
+ <span class="preason">${i18next.t('ai-assistant.text.proposal-unconfirmed', { defaultValue: 'sent earlier - no answer received' })}</span>
3978
+ <button class="run" @click=${() => this.checkProposal(p, key)}>
3979
+ ${i18next.t('ai-assistant.button.check-proposal', { defaultValue: 'check result' })}
3980
+ </button>
3981
+ `
3982
+ : html`
3983
+ <button
3984
+ class="run"
3985
+ ?disabled=${sent}
3986
+ @click=${() => this.executeProposal(p, key)}>
3987
+ ${sent
3988
+ ? this.proposalStateText(key)
3989
+ : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
3990
+ </button>
3991
+ `}
3967
3992
  ${/*
3968
3993
  Drawn only where the host has said it staged this candidate, in the host's own
3969
3994
  words. Not knowing is not a claim: with no word from the host, nothing is drawn.
@@ -4120,10 +4145,34 @@ export class OxAssistantChat extends LitElement {
4120
4145
 
4121
4146
  private elapsedTimer?: ReturnType<typeof setInterval>
4122
4147
 
4123
- private executeProposal(p: any, key: string) {
4148
+ private async executeProposal(p: any, key: string) {
4149
+ if (this.sentProposals.has(key)) return
4124
4150
  this.sentProposals = new Set(this.sentProposals).add(key)
4125
4151
  this.proposalSentAt.set(key, Date.now())
4126
4152
  this._startElapsedTicker()
4153
+
4154
+ /*
4155
+ * The server records the request first and answers whether this conversation has taken the proposal
4156
+ * already (ADR-0097 Q-D4 ④). Only a granted request reaches the host. A refused one - taken in another
4157
+ * window, or before a refresh - leaves the card to the rows; a failed one leaves it as it was, and the
4158
+ * error is said by the client's own error line. Either way the host is not asked.
4159
+ */
4160
+ if (this.sessionId) {
4161
+ let granted = false
4162
+ try {
4163
+ granted = await this.requestProposal(key)
4164
+ } catch {
4165
+ granted = false
4166
+ }
4167
+ if (!granted) {
4168
+ const left = new Set(this.sentProposals)
4169
+ left.delete(key)
4170
+ this.sentProposals = left
4171
+ this.proposalSentAt.delete(key)
4172
+ return
4173
+ }
4174
+ }
4175
+
4127
4176
  this.dispatchEvent(
4128
4177
  new CustomEvent('assistant-proposal-execute', {
4129
4178
  /*
@@ -4137,6 +4186,44 @@ export class OxAssistantChat extends LitElement {
4137
4186
  )
4138
4187
  }
4139
4188
 
4189
+ private async requestProposal(key: string): Promise<boolean> {
4190
+ const result = await client.mutate({
4191
+ mutation: gql`
4192
+ mutation ($id: String!, $key: String!) {
4193
+ requestProposal(sessionId: $id, proposalKey: $key)
4194
+ }
4195
+ `,
4196
+ variables: { id: this.sessionId, key }
4197
+ })
4198
+ if (result.errors?.length) throw new Error(result.errors[0].message)
4199
+ return result.data?.requestProposal === true
4200
+ }
4201
+
4202
+ /**
4203
+ * "Check result" on a card that was requested and never answered: the host is asked what happened
4204
+ * (`AssistantSurface.proposalOutcome`), never to run it again. The dock reports what it learns.
4205
+ */
4206
+ private checkProposal(p: any, key: string) {
4207
+ this.dispatchEvent(
4208
+ new CustomEvent('assistant-proposal-check', {
4209
+ detail: { proposal: p, sessionId: this.sessionId, proposalKey: key },
4210
+ bubbles: true,
4211
+ composed: true
4212
+ })
4213
+ )
4214
+ }
4215
+
4216
+ /** Keys whose run was requested - from the conversation's rows, like the outcomes. */
4217
+ private get proposalRequests(): Set<string> {
4218
+ const requested = new Set<string>()
4219
+ for (const line of this.lines) {
4220
+ if (line.role !== 'system') continue
4221
+ const key = parseProposalRequestLine(line.content ?? '')
4222
+ if (key) requested.add(key)
4223
+ }
4224
+ return requested
4225
+ }
4226
+
4140
4227
  /**
4141
4228
  * 호스트가 무엇을 했는지 — 대화에 남은 `system` 행에서 읽는다(ADR-0070 A12-a 결정 ①).
4142
4229
  *
@@ -4447,6 +4534,7 @@ export class OxAssistantChat extends LitElement {
4447
4534
  }
4448
4535
 
4449
4536
  private async send() {
4537
+ if (this.sendBlocked) { this.errorMessage = this.sendBlocked; return }
4450
4538
  const text = this.input.trim()
4451
4539
  const attachmentsToSend = [...this.attachments]
4452
4540
  if ((!text && attachmentsToSend.length === 0) || this.busy) return
@@ -4508,6 +4596,7 @@ export class OxAssistantChat extends LitElement {
4508
4596
  modelFingerprint: assistantModelFingerprint(liveBoard) }
4509
4597
  } catch (error) {
4510
4598
  this.errorMessage = error instanceof Error ? error.message : String(error)
4599
+ this.attachments = attachmentsToSend
4511
4600
  return
4512
4601
  }
4513
4602
 
@@ -31,6 +31,7 @@ import {
31
31
  } from '../utils/assistant-surface.js'
32
32
  import { runProposal, type ProposalContext, type ProposalOutcome } from '../utils/assistant-proposals.js'
33
33
  import { holdAssistantAsk, subscribeAssistantAsk, takeAssistantAsk, type AssistantAsk } from '../utils/assistant-ask.js'
34
+ import { surfaceTransition } from '../utils/assistant-navigation.js'
34
35
 
35
36
  export const ASSISTANT_DOCK_VIEWPART = 'assistant-dock'
36
37
 
@@ -159,6 +160,34 @@ export class AssistantDock extends localize(i18next)(LitElement) {
159
160
  --md-icon-size: 18px;
160
161
  }
161
162
 
163
+ /*
164
+ * An open conversation is read by everyone in the domain - the dock says so on every screen of it, not
165
+ * only in the list (2026-09-30: open and private were one grey word apart, and an owner's question about
166
+ * the books sat in every employee's list). A private one draws nothing extra: it is the default.
167
+ */
168
+ :host([audience='open']) {
169
+ border-left: 3px solid var(--md-sys-color-tertiary);
170
+ }
171
+
172
+ .audience {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 6px;
176
+ flex: none;
177
+ padding: 6px 10px;
178
+ background: var(--md-sys-color-tertiary-container);
179
+ color: var(--md-sys-color-on-tertiary-container);
180
+ border-bottom: 1px solid var(--md-sys-color-tertiary);
181
+ font-size: 12px;
182
+ font-weight: 600;
183
+ line-height: 1.35;
184
+ }
185
+
186
+ .audience md-icon {
187
+ --md-icon-size: 16px;
188
+ flex: none;
189
+ }
190
+
162
191
  ox-assistant-session-toolbar {
163
192
  flex: none;
164
193
  min-width: 0;
@@ -191,6 +220,35 @@ export class AssistantDock extends localize(i18next)(LitElement) {
191
220
  gap: 4px;
192
221
  font: inherit;
193
222
  }
223
+
224
+ .next-step {
225
+ display: flex;
226
+ align-items: center;
227
+ justify-content: space-between;
228
+ gap: 10px;
229
+ padding: 8px 12px;
230
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
231
+ background: var(--md-sys-color-surface-container-low);
232
+ color: var(--md-sys-color-on-surface-variant);
233
+ font-size: 12px;
234
+ line-height: 1.4;
235
+ }
236
+
237
+ .next-step a {
238
+ display: inline-flex;
239
+ align-items: center;
240
+ gap: 3px;
241
+ flex: none;
242
+ color: var(--md-sys-color-primary);
243
+ font-weight: 650;
244
+ text-decoration: none;
245
+ }
246
+
247
+ .next-step a:hover { text-decoration: underline; }
248
+ .next-step md-icon { --md-icon-size: 16px; }
249
+ ox-assistant-chat[hidden] { display: none; }
250
+ .transition { padding: 10px 12px; font-size: 13px; line-height: 1.5; border-bottom: 1px solid var(--md-sys-color-outline-variant); background: var(--md-sys-color-surface-container-low); }
251
+ .transition button { display: block; margin-top: 6px; font: inherit; }
194
252
  `
195
253
  ]
196
254
 
@@ -211,6 +269,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
211
269
  @state() private showingHistory = false
212
270
  @state() private conversationView = 0
213
271
  @state() private asking?: { ask?: string; draft?: string; requestId: string }
272
+ @state() private screenChanged = false
273
+ @state() private navigationNotice = ''
274
+ private openedIdentity = ''
214
275
 
215
276
  private app = new AppController(this)
216
277
  /** The kind the person picked; kept while the surface stays the same. */
@@ -237,11 +298,24 @@ export class AssistantDock extends localize(i18next)(LitElement) {
237
298
 
238
299
  private onStartNewSession = () => void this.newConversation()
239
300
 
301
+ /** Who reads the open conversation - absent until the list or the create answer says. */
302
+ private get audience(): string | undefined {
303
+ return this.sessions.find(session => session.id === this.sessionId)?.visibility || undefined
304
+ }
305
+
306
+ protected updated(changed: Map<PropertyKey, unknown>) {
307
+ super.updated?.(changed as any)
308
+ const audience = !this.showingHistory && this.audience === 'open' ? 'open' : null
309
+ if (audience) this.setAttribute('audience', audience)
310
+ else this.removeAttribute('audience')
311
+ }
312
+
240
313
  connectedCallback() {
241
314
  super.connectedCallback()
242
315
  this.addEventListener('assistant-start-new-session', this.onStartNewSession)
243
316
  this.unsubscribers.push(
244
317
  subscribeAssistantSurface(() => this.follow()),
318
+ store.subscribe(() => { if (this.openedIdentity !== this.identityKey()) this.follow() }),
245
319
  subscribeAssistantAsk(() => void this.takeAsk()),
246
320
  subscribeSessionActivity(activity => {
247
321
  /* A rename or a new conversation elsewhere on this anchor - the tabs read again. */
@@ -260,24 +334,75 @@ export class AssistantDock extends localize(i18next)(LitElement) {
260
334
  super.disconnectedCallback()
261
335
  }
262
336
 
263
- private scopeKey(surface = this.surface) {
337
+ private scopeKey(surface: AssistantSurface | undefined) {
264
338
  return JSON.stringify([this.app.contextPath, surface?.key ?? ''])
265
339
  }
266
340
 
341
+ private identityKey() {
342
+ const user = (store.getState() as any).auth?.user
343
+ return JSON.stringify([this.app.contextPath, user?.id || user?.email || ''])
344
+ }
345
+
346
+ private get navigationState() {
347
+ return (this.renderRoot.querySelector('ox-assistant-chat') as any)?.navigationState ?? { busy: false, draft: false }
348
+ }
349
+
350
+ private async mayLeaveConversation(): Promise<boolean> {
351
+ if (this.navigationState.busy) {
352
+ notify({ level: 'info', message: t('wait-before-switch', 'Wait for the current response before changing conversations.') })
353
+ return false
354
+ }
355
+ if (!this.navigationState.draft) return true
356
+ const identity = this.identityKey()
357
+ const accepted = await OxPrompt.open({
358
+ type: 'warning', title: t('switch-title', 'Change conversation?'),
359
+ text: t('switch-draft', 'Your unsent text and attachments will not move to the other conversation.'),
360
+ confirmButton: { text: t('switch-confirm', 'Discard draft and switch') },
361
+ cancelButton: { text: t('stay', 'Keep writing') }
362
+ })
363
+ return !!accepted && identity === this.identityKey()
364
+ }
365
+
366
+ private async requestSurface(surface: AssistantSurface | undefined) {
367
+ if (!(await this.mayLeaveConversation())) return
368
+ this.chosenKind = ''
369
+ this.surface = surface
370
+ this.navigationNotice = surface ? t('now-about', 'Conversation subject: {name}', { name: surface.label() }) : ''
371
+ await this.open()
372
+ }
373
+
267
374
  /** The screen changed - a different surface or domain is a different conversation. */
268
375
  private follow(force = false) {
269
376
  const next = currentAssistantSurface()
270
- if (!force && next === this.surface && this.scopeKey(next) === this.openedFor) {
271
- /* The same conversation, told that its words changed - a name loaded, a board renamed on save. */
377
+ const transition = surfaceTransition({
378
+ identityChanged: this.openedIdentity !== this.identityKey(),
379
+ sameSubject: this.scopeKey(next) === this.openedFor,
380
+ active: isAnchor(this.anchor) && (!!this.sessionId || this.navigationState.draft || this.navigationState.busy)
381
+ })
382
+ if (!force && transition === 'refresh') {
383
+ this.surface = next
384
+ this.screenChanged = false
272
385
  this.requestUpdate()
273
386
  return
274
387
  }
388
+ if (!force && transition === 'offer') {
389
+ this.screenChanged = true
390
+ return
391
+ }
392
+ if (transition === 'reset') {
393
+ this.asking = undefined
394
+ this.capability = 'unknown'
395
+ if (this.openedIdentity) takeAssistantAsk()
396
+ }
397
+ this.navigationNotice = transition === 'follow' && next ? t('now-about', 'Conversation subject: {name}', { name: next.label() }) : ''
275
398
  if (next?.key !== this.surface?.key) this.chosenKind = ''
276
399
  this.surface = next
277
400
  void this.open()
278
401
  }
279
402
 
280
403
  private async open(): Promise<void> {
404
+ this.openedIdentity = this.identityKey()
405
+ this.screenChanged = false
281
406
  const ticket = ++this.revision
282
407
  this.conversationView++
283
408
  this.showingHistory = false
@@ -285,7 +410,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
285
410
  this.loading = true
286
411
  this.failure = ''
287
412
  this.anchor = undefined
288
- this.openedFor = this.scopeKey()
413
+ this.openedFor = this.scopeKey(this.surface)
289
414
 
290
415
  try {
291
416
  if (this.capability !== 'configured') {
@@ -341,13 +466,15 @@ export class AssistantDock extends localize(i18next)(LitElement) {
341
466
 
342
467
  private async newConversation() {
343
468
  if (this.loading || !isAnchor(this.anchor)) return
469
+ if (!(await this.mayLeaveConversation())) return
344
470
  this.showingHistory = false
345
471
  const revision = this.controller.state.revision
346
472
  await this.controller.reset()
347
473
  if (revision !== this.controller.state.revision) this.conversationView++
348
474
  }
349
475
 
350
- private selectSession(id: string, pickedKind?: string) {
476
+ private async selectSession(id: string, pickedKind?: string) {
477
+ if (id !== this.sessionId && !(await this.mayLeaveConversation())) return
351
478
  this.showingHistory = false
352
479
  const follow = followPickedConversation(pickedKind, this.kind)
353
480
  if (follow) {
@@ -360,9 +487,10 @@ export class AssistantDock extends localize(i18next)(LitElement) {
360
487
  if (this.controller.select(id) && previous !== id) this.conversationView++
361
488
  }
362
489
 
363
- private onModeChange = (event: CustomEvent) => {
490
+ private onModeChange = async (event: CustomEvent) => {
364
491
  const kind = event.detail?.kind
365
492
  if (!kind || kind === this.kind) return
493
+ if (!(await this.mayLeaveConversation())) return
366
494
  this.chosenKind = kind
367
495
  void this.open()
368
496
  }
@@ -371,6 +499,11 @@ export class AssistantDock extends localize(i18next)(LitElement) {
371
499
 
372
500
  private async takeAsk() {
373
501
  if (this.loading || !this.anchor) return
502
+ if (this.screenChanged || this.navigationState.busy || this.navigationState.draft) {
503
+ const waiting = takeAssistantAsk()
504
+ if (waiting) notify({ level: 'info', message: t('ask-kept-current', 'The current conversation was kept. Switch to the intended subject before asking again.') })
505
+ return
506
+ }
374
507
  if (!isAnchor(this.anchor)) {
375
508
  /*
376
509
  A question is not sent later on its own, once some conversation happens to stand - by then the person is looking
@@ -442,6 +575,8 @@ export class AssistantDock extends localize(i18next)(LitElement) {
442
575
  /* ── proposals ───────────────────────────────────────────────────────────────────── */
443
576
 
444
577
  private proposalContext(sessionId?: string): ProposalContext {
578
+ const identity = this.identityKey()
579
+ const scope = this.openedFor
445
580
  return {
446
581
  sessionId,
447
582
  surface: this.surface,
@@ -452,7 +587,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
452
587
  text,
453
588
  confirmButton: { text: confirmText ?? t('confirm', 'OK') },
454
589
  cancelButton: { text: cancelText ?? t('cancel', 'Cancel') }
455
- })),
590
+ })) && identity === this.identityKey() && scope === this.scopeKey(currentAssistantSurface()),
456
591
  notify: (level, message) => notify({ level, message })
457
592
  }
458
593
  }
@@ -476,7 +611,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
476
611
  /** Every proposal ends in exactly one reported outcome - a kind nobody handles is `unavailable`. */
477
612
  private onProposal = async (event: CustomEvent) => {
478
613
  const { proposal, sessionId, proposalKey } = event.detail ?? {}
479
- const outcome = await runProposal(proposal, this.proposalContext(sessionId))
614
+ const outcome: ProposalOutcome = this.openedFor !== this.scopeKey(currentAssistantSurface()) || this.openedIdentity !== this.identityKey()
615
+ ? { verdict: 'unavailable', errorCode: 'conversation-screen-changed', note: t('return-to-subject', 'Return to the conversation subject before applying this proposal.') }
616
+ : await runProposal(proposal, this.proposalContext(sessionId))
480
617
  if (outcome.verdict === 'unavailable' && outcome.errorCode === 'unknown-proposal-kind') {
481
618
  notify({ level: 'error', message: t('proposal-not-runnable', 'this proposal cannot be run on this screen.') })
482
619
  }
@@ -485,6 +622,33 @@ export class AssistantDock extends localize(i18next)(LitElement) {
485
622
  if (outcome.note && sessionId) await this.appendNote(sessionId, outcome.note)
486
623
  }
487
624
 
625
+ /**
626
+ * "Check result" - asks the surface what became of a requested proposal and reports only what it learns.
627
+ * Nothing is run: a lost answer never turns into a second application (ADR-0097 Q-D4 ④).
628
+ */
629
+ private onProposalCheck = async (event: CustomEvent) => {
630
+ const { proposal, sessionId, proposalKey } = event.detail ?? {}
631
+ const ask = this.surface?.proposalOutcome
632
+ if (!ask) {
633
+ notify({ level: 'info', message: t('proposal-result-uncheckable', 'this screen cannot check that result. open the screen where it was applied.') })
634
+ return
635
+ }
636
+
637
+ let outcome: ProposalOutcome | undefined
638
+ try {
639
+ outcome = await ask.call(this.surface, proposalKey, proposal)
640
+ } catch {
641
+ outcome = undefined
642
+ }
643
+ if (!outcome?.verdict) {
644
+ notify({ level: 'info', message: t('proposal-result-unknown', 'the result is still unknown. it was not applied again.') })
645
+ return
646
+ }
647
+
648
+ await this.report(sessionId, proposalKey, outcome)
649
+ if (outcome.note && sessionId) await this.appendNote(sessionId, outcome.note)
650
+ }
651
+
488
652
  private async appendNote(sessionId: string, note: string) {
489
653
  try {
490
654
  await client.mutate({
@@ -503,15 +667,22 @@ export class AssistantDock extends localize(i18next)(LitElement) {
503
667
  /* ── board surfaces: the chat's scene edits land on the open board ───────────────── */
504
668
 
505
669
  private onScenePatch = async (event: CustomEvent) => {
670
+ if (!this.mayApplyOnScreen()) return
506
671
  const board = this.surface?.board
507
672
  if (!board) return
508
673
  const result = await board.applyPatch(event.detail)
509
674
  if (result?.missed) notify({ level: 'info', message: t('patch-missed', '{missed} of the changes could not be applied.', { missed: result.missed }) })
510
675
  }
511
676
 
512
- private onBoardRevert = (event: CustomEvent) => void this.surface?.board?.revertPatch?.(event.detail?.patchId)
677
+ private mayApplyOnScreen() {
678
+ if (this.openedFor === this.scopeKey(currentAssistantSurface()) && this.openedIdentity === this.identityKey()) return true
679
+ notify({ level: 'info', message: t('return-to-subject', 'Return to the conversation subject before applying this proposal.') })
680
+ return false
681
+ }
513
682
 
514
- private onSceneAction = (event: CustomEvent) => void this.surface?.board?.applyAction?.(event.detail)
683
+ private onBoardRevert = (event: CustomEvent) => { if (this.mayApplyOnScreen()) void this.surface?.board?.revertPatch?.(event.detail?.patchId) }
684
+
685
+ private onSceneAction = (event: CustomEvent) => { if (this.mayApplyOnScreen()) void this.surface?.board?.applyAction?.(event.detail) }
515
686
 
516
687
  /* ── drawing ─────────────────────────────────────────────────────────────────────── */
517
688
 
@@ -540,16 +711,16 @@ export class AssistantDock extends localize(i18next)(LitElement) {
540
711
  <md-icon>home</md-icon>
541
712
  </button>`
542
713
  : nothing}
543
- ${ready
714
+ ${ready && this.showingHistory
544
715
  ? html`<button
545
716
  class="sbtn"
546
717
  title=${this.showingHistory ? t('back-to-conversation', 'back to the conversation') : t('conversations', 'conversations')}
547
718
  @click=${() => (this.showingHistory = !this.showingHistory)}
548
719
  >
549
- <md-icon>${this.showingHistory ? 'forum' : 'list'}</md-icon>
720
+ <md-icon>arrow_back</md-icon>${t('back-to-conversation', 'back to the conversation')}
550
721
  </button>`
551
722
  : nothing}
552
- <button class="sbtn" title=${t('rebind', 'follow this screen again')} @click=${() => this.follow(true)}>
723
+ <button class="sbtn" title=${t('rebind', 'follow this screen again')} @click=${() => this.requestSurface(currentAssistantSurface())}>
553
724
  <md-icon>refresh</md-icon>
554
725
  </button>
555
726
  <button class="sbtn" title=${t('close', 'close')} @click=${() => closeOverlay(this.viewpart)}>
@@ -561,9 +732,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
561
732
 
562
733
  /** The home button: talk about the application instead of this screen, until the screen changes. */
563
734
  private useSurface(surface: AssistantSurface) {
564
- this.surface = surface
565
- this.chosenKind = ''
566
- void this.open()
735
+ void this.requestSurface(surface)
567
736
  }
568
737
 
569
738
  render(): TemplateResult {
@@ -578,6 +747,14 @@ export class AssistantDock extends localize(i18next)(LitElement) {
578
747
 
579
748
  return html`
580
749
  ${this.header()}
750
+ ${surface?.nextStep ? html`<div class="next-step" role="status">
751
+ <span>${surface.nextStep.description?.() || ''}</span>
752
+ <a href=${surface.nextStep.href}>${surface.nextStep.label()}<md-icon>arrow_forward</md-icon></a>
753
+ </div>` : nothing}
754
+ ${this.screenChanged ? html`<div class="transition" role="status">
755
+ ${t('screen-and-conversation', 'Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.', { screen: currentAssistantSurface()?.label() || t('no-subject', 'No subject'), conversation: surface?.label() || '' })}
756
+ <button class="sbtn" @click=${() => this.requestSurface(currentAssistantSurface())}>${t('switch-to-screen', 'Talk about this screen')}</button>
757
+ </div>` : this.navigationNotice ? html`<div class="transition" role="status">${this.navigationNotice}</div>` : nothing}
581
758
  ${ready && this.showingHistory
582
759
  ? html`<ox-assistant-session-manager
583
760
  .anchorType=${(anchor as any).anchorType}
@@ -585,6 +762,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
585
762
  .sessionId=${this.sessionId}
586
763
  .kind=${this.kind}
587
764
  .kindLabels=${this.kindLabels}
765
+ .scopeLabel=${t('conversation-scope', 'Conversations · {name}', { name: surface?.label() || '' })}
588
766
  @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId, event.detail.kind)}
589
767
  @assistant-session-reset=${() => this.newConversation()}
590
768
  @assistant-session-changed=${() => this.reloadSessions()}
@@ -597,21 +775,30 @@ export class AssistantDock extends localize(i18next)(LitElement) {
597
775
  .loading=${this.loading}
598
776
  .error=${this.failure}
599
777
  historyEnabled
778
+ singleConversation
600
779
  @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId)}
601
780
  @assistant-session-reset=${() => this.newConversation()}
602
781
  @assistant-session-history=${() => (this.showingHistory = true)}
603
782
  @assistant-session-retry=${() => this.open()}
604
783
  ></ox-assistant-session-toolbar>`
605
784
  : nothing}
606
- ${ready && !this.showingHistory && !this.loading && !this.failure
785
+ ${ready && !this.showingHistory && this.audience === 'open'
786
+ ? html`<div class="audience" role="status">
787
+ <md-icon>public</md-icon>
788
+ <span>${t('audience-open-band', 'Public conversation - everyone in this domain can read and write here.')}</span>
789
+ </div>`
790
+ : nothing}
791
+ ${ready && !this.loading && !this.failure
607
792
  ? keyed(
608
793
  this.conversationView,
609
794
  html`<ox-assistant-chat
795
+ ?hidden=${this.showingHistory}
796
+ .sendBlocked=${this.screenChanged ? t('return-to-subject', 'Return to the conversation subject before sending or applying changes.') : ''}
610
797
  .sessionId=${this.sessionId}
611
798
  .prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
612
799
  .contextProvider=${() => {
613
800
  /* The screen moved on between writing and sending - do not answer about the old one. */
614
- if (openedFor !== this.scopeKey(currentAssistantSurface() ?? surface)) throw new Error(t('screen-changed', 'the screen changed while you were writing. send it again.'))
801
+ if (openedFor !== this.scopeKey(currentAssistantSurface()) || this.openedIdentity !== this.identityKey()) throw new Error(t('return-to-subject', 'Return to the conversation subject before applying this proposal.'))
615
802
  return { hostContext: surface?.hostContext?.() ?? {} }
616
803
  }}
617
804
  .mode=${mode}
@@ -631,6 +818,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
631
818
  .draftRequestId=${this.asking?.requestId}
632
819
  @assistant-mode-change=${this.onModeChange}
633
820
  @assistant-proposal-execute=${this.onProposal}
821
+ @assistant-proposal-check=${this.onProposalCheck}
634
822
  @scene-edit-patch=${this.onScenePatch}
635
823
  @board-edit-revert=${this.onBoardRevert}
636
824
  @scene-action-execute=${this.onSceneAction}
@@ -24,9 +24,13 @@ const MODE_VIEW = {
24
24
  }
25
25
 
26
26
  /** The board product, which declares no mode and names its own door. */
27
+ /*
28
+ * A host on the neutral door. A board host (`chatEndpoint: 'boardAIChat'`) keeps its own door even with
29
+ * a mode - it alone carries the board document (16d14bce2b, ADR-0061 decision 0-1) - and is pinned below.
30
+ */
27
31
  const HOST_PROPERTIES = {
28
32
  hostContext: { boardId: 'B-1' },
29
- chatEndpoint: 'boardAIChat' as const
33
+ chatEndpoint: 'assistantChat' as const
30
34
  }
31
35
 
32
36
  const GLOBAL_DEFAULTS = {
@@ -70,7 +74,7 @@ describe('a mode names the conversation; the server says what it offers', () =>
70
74
 
71
75
  describe('a host with no mode', () => {
72
76
  test('gets its own properties and the registered defaults, as before modes existed', () => {
73
- const c = resolveConversation(undefined, HOST_PROPERTIES, GLOBAL_DEFAULTS)
77
+ const c = resolveConversation(undefined, { ...HOST_PROPERTIES, chatEndpoint: 'boardAIChat' }, GLOBAL_DEFAULTS)
74
78
 
75
79
  expect(c.hostContext).toEqual({ boardId: 'B-1' })
76
80
  expect(c.slashTemplates).toEqual(GLOBAL_DEFAULTS.slashTemplates)
@@ -145,3 +149,13 @@ describe('followPickedConversation', () => {
145
149
  expect(followPickedConversation('', 'figure.modeller')).toBeUndefined()
146
150
  })
147
151
  })
152
+
153
+ describe('a board host keeps the board door', () => {
154
+ test('★ with a mode, the door stays boardAIChat and the board defaults fill what the mode leaves out', () => {
155
+ const c = resolveConversation(MODE, { ...HOST_PROPERTIES, chatEndpoint: 'boardAIChat' }, GLOBAL_DEFAULTS, undefined)
156
+
157
+ expect(c.kind).toBe('twin.space')
158
+ expect(c.chatEndpoint).toBe('boardAIChat')
159
+ expect(c.catalogEntries).toEqual(GLOBAL_DEFAULTS.catalogEntries)
160
+ })
161
+ })