@things-factory/ai-assistant 10.1.58 → 10.1.81

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 (74) hide show
  1. package/client/components/assistant-chat.ts +102 -13
  2. package/client/components/assistant-dock.ts +160 -18
  3. package/client/components/assistant-mode.test.ts +16 -2
  4. package/client/components/assistant-session-manager.ts +52 -25
  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-surface.ts +21 -0
  12. package/dist-client/components/assistant-chat.d.ts +15 -0
  13. package/dist-client/components/assistant-chat.js +106 -13
  14. package/dist-client/components/assistant-chat.js.map +1 -1
  15. package/dist-client/components/assistant-dock.d.ts +13 -0
  16. package/dist-client/components/assistant-dock.js +175 -20
  17. package/dist-client/components/assistant-dock.js.map +1 -1
  18. package/dist-client/components/assistant-mode.test.js +14 -2
  19. package/dist-client/components/assistant-mode.test.js.map +1 -1
  20. package/dist-client/components/assistant-session-manager.d.ts +4 -0
  21. package/dist-client/components/assistant-session-manager.js +68 -26
  22. package/dist-client/components/assistant-session-manager.js.map +1 -1
  23. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  24. package/dist-client/components/assistant-session-toolbar.js +8 -2
  25. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  26. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  27. package/dist-client/components/chat-echo-dedup.js +2 -0
  28. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  29. package/dist-client/components/proposal-outcome-line.d.ts +29 -0
  30. package/dist-client/components/proposal-outcome-line.js +15 -0
  31. package/dist-client/components/proposal-outcome-line.js.map +1 -1
  32. package/dist-client/components/proposal-request.test.d.ts +1 -0
  33. package/dist-client/components/proposal-request.test.js +45 -0
  34. package/dist-client/components/proposal-request.test.js.map +1 -0
  35. package/dist-client/tsconfig.tsbuildinfo +1 -1
  36. package/dist-client/utils/assistant-navigation.d.ts +6 -0
  37. package/dist-client/utils/assistant-navigation.js +9 -0
  38. package/dist-client/utils/assistant-navigation.js.map +1 -0
  39. package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
  40. package/dist-client/utils/assistant-navigation.test.js +37 -0
  41. package/dist-client/utils/assistant-navigation.test.js.map +1 -0
  42. package/dist-client/utils/assistant-surface.d.ts +19 -0
  43. package/dist-client/utils/assistant-surface.js.map +1 -1
  44. package/dist-server/service/assistant-chat-resolver.js +1 -0
  45. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  46. package/dist-server/service/chat-message/chat-message.d.ts +10 -0
  47. package/dist-server/service/chat-message/chat-message.js +29 -0
  48. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  49. package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
  50. package/dist-server/service/chat-message/proposal-outcome.js +35 -1
  51. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
  52. package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
  53. package/dist-server/service/chat-session/session-inbox.js +7 -0
  54. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  55. package/dist-server/service/chat-session-resolver.d.ts +1 -0
  56. package/dist-server/service/chat-session-resolver.js +63 -9
  57. package/dist-server/service/chat-session-resolver.js.map +1 -1
  58. package/dist-server/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +7 -7
  60. package/server/service/assistant-chat-resolver.ts +1 -0
  61. package/server/service/chat-message/chat-message.ts +32 -0
  62. package/server/service/chat-message/proposal-outcome.test.ts +53 -2
  63. package/server/service/chat-message/proposal-outcome.ts +33 -0
  64. package/server/service/chat-session/session-inbox.ts +7 -0
  65. package/server/service/chat-session-resolver.ts +71 -12
  66. package/test/assistant-dock.test.ts +8 -3
  67. package/test/assistant-session-toolbar.test.ts +7 -1
  68. package/test/open-doors.test.ts +2 -1
  69. package/test/session-discovery.test.ts +42 -0
  70. package/translations/en.json +26 -1
  71. package/translations/ja.json +26 -1
  72. package/translations/ko.json +26 -1
  73. package/translations/ms.json +26 -1
  74. package/translations/zh.json +26 -1
@@ -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
 
@@ -191,6 +192,35 @@ export class AssistantDock extends localize(i18next)(LitElement) {
191
192
  gap: 4px;
192
193
  font: inherit;
193
194
  }
195
+
196
+ .next-step {
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: space-between;
200
+ gap: 10px;
201
+ padding: 8px 12px;
202
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
203
+ background: var(--md-sys-color-surface-container-low);
204
+ color: var(--md-sys-color-on-surface-variant);
205
+ font-size: 12px;
206
+ line-height: 1.4;
207
+ }
208
+
209
+ .next-step a {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: 3px;
213
+ flex: none;
214
+ color: var(--md-sys-color-primary);
215
+ font-weight: 650;
216
+ text-decoration: none;
217
+ }
218
+
219
+ .next-step a:hover { text-decoration: underline; }
220
+ .next-step md-icon { --md-icon-size: 16px; }
221
+ ox-assistant-chat[hidden] { display: none; }
222
+ .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); }
223
+ .transition button { display: block; margin-top: 6px; font: inherit; }
194
224
  `
195
225
  ]
196
226
 
@@ -211,6 +241,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
211
241
  @state() private showingHistory = false
212
242
  @state() private conversationView = 0
213
243
  @state() private asking?: { ask?: string; draft?: string; requestId: string }
244
+ @state() private screenChanged = false
245
+ @state() private navigationNotice = ''
246
+ private openedIdentity = ''
214
247
 
215
248
  private app = new AppController(this)
216
249
  /** The kind the person picked; kept while the surface stays the same. */
@@ -242,6 +275,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
242
275
  this.addEventListener('assistant-start-new-session', this.onStartNewSession)
243
276
  this.unsubscribers.push(
244
277
  subscribeAssistantSurface(() => this.follow()),
278
+ store.subscribe(() => { if (this.openedIdentity !== this.identityKey()) this.follow() }),
245
279
  subscribeAssistantAsk(() => void this.takeAsk()),
246
280
  subscribeSessionActivity(activity => {
247
281
  /* A rename or a new conversation elsewhere on this anchor - the tabs read again. */
@@ -260,24 +294,75 @@ export class AssistantDock extends localize(i18next)(LitElement) {
260
294
  super.disconnectedCallback()
261
295
  }
262
296
 
263
- private scopeKey(surface = this.surface) {
297
+ private scopeKey(surface: AssistantSurface | undefined) {
264
298
  return JSON.stringify([this.app.contextPath, surface?.key ?? ''])
265
299
  }
266
300
 
301
+ private identityKey() {
302
+ const user = (store.getState() as any).auth?.user
303
+ return JSON.stringify([this.app.contextPath, user?.id || user?.email || ''])
304
+ }
305
+
306
+ private get navigationState() {
307
+ return (this.renderRoot.querySelector('ox-assistant-chat') as any)?.navigationState ?? { busy: false, draft: false }
308
+ }
309
+
310
+ private async mayLeaveConversation(): Promise<boolean> {
311
+ if (this.navigationState.busy) {
312
+ notify({ level: 'info', message: t('wait-before-switch', 'Wait for the current response before changing conversations.') })
313
+ return false
314
+ }
315
+ if (!this.navigationState.draft) return true
316
+ const identity = this.identityKey()
317
+ const accepted = await OxPrompt.open({
318
+ type: 'warning', title: t('switch-title', 'Change conversation?'),
319
+ text: t('switch-draft', 'Your unsent text and attachments will not move to the other conversation.'),
320
+ confirmButton: { text: t('switch-confirm', 'Discard draft and switch') },
321
+ cancelButton: { text: t('stay', 'Keep writing') }
322
+ })
323
+ return !!accepted && identity === this.identityKey()
324
+ }
325
+
326
+ private async requestSurface(surface: AssistantSurface | undefined) {
327
+ if (!(await this.mayLeaveConversation())) return
328
+ this.chosenKind = ''
329
+ this.surface = surface
330
+ this.navigationNotice = surface ? t('now-about', 'Conversation subject: {name}', { name: surface.label() }) : ''
331
+ await this.open()
332
+ }
333
+
267
334
  /** The screen changed - a different surface or domain is a different conversation. */
268
335
  private follow(force = false) {
269
336
  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. */
337
+ const transition = surfaceTransition({
338
+ identityChanged: this.openedIdentity !== this.identityKey(),
339
+ sameSubject: this.scopeKey(next) === this.openedFor,
340
+ active: isAnchor(this.anchor) && (!!this.sessionId || this.navigationState.draft || this.navigationState.busy)
341
+ })
342
+ if (!force && transition === 'refresh') {
343
+ this.surface = next
344
+ this.screenChanged = false
272
345
  this.requestUpdate()
273
346
  return
274
347
  }
348
+ if (!force && transition === 'offer') {
349
+ this.screenChanged = true
350
+ return
351
+ }
352
+ if (transition === 'reset') {
353
+ this.asking = undefined
354
+ this.capability = 'unknown'
355
+ if (this.openedIdentity) takeAssistantAsk()
356
+ }
357
+ this.navigationNotice = transition === 'follow' && next ? t('now-about', 'Conversation subject: {name}', { name: next.label() }) : ''
275
358
  if (next?.key !== this.surface?.key) this.chosenKind = ''
276
359
  this.surface = next
277
360
  void this.open()
278
361
  }
279
362
 
280
363
  private async open(): Promise<void> {
364
+ this.openedIdentity = this.identityKey()
365
+ this.screenChanged = false
281
366
  const ticket = ++this.revision
282
367
  this.conversationView++
283
368
  this.showingHistory = false
@@ -285,7 +370,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
285
370
  this.loading = true
286
371
  this.failure = ''
287
372
  this.anchor = undefined
288
- this.openedFor = this.scopeKey()
373
+ this.openedFor = this.scopeKey(this.surface)
289
374
 
290
375
  try {
291
376
  if (this.capability !== 'configured') {
@@ -341,13 +426,15 @@ export class AssistantDock extends localize(i18next)(LitElement) {
341
426
 
342
427
  private async newConversation() {
343
428
  if (this.loading || !isAnchor(this.anchor)) return
429
+ if (!(await this.mayLeaveConversation())) return
344
430
  this.showingHistory = false
345
431
  const revision = this.controller.state.revision
346
432
  await this.controller.reset()
347
433
  if (revision !== this.controller.state.revision) this.conversationView++
348
434
  }
349
435
 
350
- private selectSession(id: string, pickedKind?: string) {
436
+ private async selectSession(id: string, pickedKind?: string) {
437
+ if (id !== this.sessionId && !(await this.mayLeaveConversation())) return
351
438
  this.showingHistory = false
352
439
  const follow = followPickedConversation(pickedKind, this.kind)
353
440
  if (follow) {
@@ -360,9 +447,10 @@ export class AssistantDock extends localize(i18next)(LitElement) {
360
447
  if (this.controller.select(id) && previous !== id) this.conversationView++
361
448
  }
362
449
 
363
- private onModeChange = (event: CustomEvent) => {
450
+ private onModeChange = async (event: CustomEvent) => {
364
451
  const kind = event.detail?.kind
365
452
  if (!kind || kind === this.kind) return
453
+ if (!(await this.mayLeaveConversation())) return
366
454
  this.chosenKind = kind
367
455
  void this.open()
368
456
  }
@@ -371,6 +459,11 @@ export class AssistantDock extends localize(i18next)(LitElement) {
371
459
 
372
460
  private async takeAsk() {
373
461
  if (this.loading || !this.anchor) return
462
+ if (this.screenChanged || this.navigationState.busy || this.navigationState.draft) {
463
+ const waiting = takeAssistantAsk()
464
+ if (waiting) notify({ level: 'info', message: t('ask-kept-current', 'The current conversation was kept. Switch to the intended subject before asking again.') })
465
+ return
466
+ }
374
467
  if (!isAnchor(this.anchor)) {
375
468
  /*
376
469
  A question is not sent later on its own, once some conversation happens to stand - by then the person is looking
@@ -442,6 +535,8 @@ export class AssistantDock extends localize(i18next)(LitElement) {
442
535
  /* ── proposals ───────────────────────────────────────────────────────────────────── */
443
536
 
444
537
  private proposalContext(sessionId?: string): ProposalContext {
538
+ const identity = this.identityKey()
539
+ const scope = this.openedFor
445
540
  return {
446
541
  sessionId,
447
542
  surface: this.surface,
@@ -452,7 +547,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
452
547
  text,
453
548
  confirmButton: { text: confirmText ?? t('confirm', 'OK') },
454
549
  cancelButton: { text: cancelText ?? t('cancel', 'Cancel') }
455
- })),
550
+ })) && identity === this.identityKey() && scope === this.scopeKey(currentAssistantSurface()),
456
551
  notify: (level, message) => notify({ level, message })
457
552
  }
458
553
  }
@@ -476,7 +571,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
476
571
  /** Every proposal ends in exactly one reported outcome - a kind nobody handles is `unavailable`. */
477
572
  private onProposal = async (event: CustomEvent) => {
478
573
  const { proposal, sessionId, proposalKey } = event.detail ?? {}
479
- const outcome = await runProposal(proposal, this.proposalContext(sessionId))
574
+ const outcome: ProposalOutcome = this.openedFor !== this.scopeKey(currentAssistantSurface()) || this.openedIdentity !== this.identityKey()
575
+ ? { verdict: 'unavailable', errorCode: 'conversation-screen-changed', note: t('return-to-subject', 'Return to the conversation subject before applying this proposal.') }
576
+ : await runProposal(proposal, this.proposalContext(sessionId))
480
577
  if (outcome.verdict === 'unavailable' && outcome.errorCode === 'unknown-proposal-kind') {
481
578
  notify({ level: 'error', message: t('proposal-not-runnable', 'this proposal cannot be run on this screen.') })
482
579
  }
@@ -485,6 +582,33 @@ export class AssistantDock extends localize(i18next)(LitElement) {
485
582
  if (outcome.note && sessionId) await this.appendNote(sessionId, outcome.note)
486
583
  }
487
584
 
585
+ /**
586
+ * "Check result" - asks the surface what became of a requested proposal and reports only what it learns.
587
+ * Nothing is run: a lost answer never turns into a second application (ADR-0097 Q-D4 ④).
588
+ */
589
+ private onProposalCheck = async (event: CustomEvent) => {
590
+ const { proposal, sessionId, proposalKey } = event.detail ?? {}
591
+ const ask = this.surface?.proposalOutcome
592
+ if (!ask) {
593
+ notify({ level: 'info', message: t('proposal-result-uncheckable', 'this screen cannot check that result. open the screen where it was applied.') })
594
+ return
595
+ }
596
+
597
+ let outcome: ProposalOutcome | undefined
598
+ try {
599
+ outcome = await ask.call(this.surface, proposalKey, proposal)
600
+ } catch {
601
+ outcome = undefined
602
+ }
603
+ if (!outcome?.verdict) {
604
+ notify({ level: 'info', message: t('proposal-result-unknown', 'the result is still unknown. it was not applied again.') })
605
+ return
606
+ }
607
+
608
+ await this.report(sessionId, proposalKey, outcome)
609
+ if (outcome.note && sessionId) await this.appendNote(sessionId, outcome.note)
610
+ }
611
+
488
612
  private async appendNote(sessionId: string, note: string) {
489
613
  try {
490
614
  await client.mutate({
@@ -503,15 +627,22 @@ export class AssistantDock extends localize(i18next)(LitElement) {
503
627
  /* ── board surfaces: the chat's scene edits land on the open board ───────────────── */
504
628
 
505
629
  private onScenePatch = async (event: CustomEvent) => {
630
+ if (!this.mayApplyOnScreen()) return
506
631
  const board = this.surface?.board
507
632
  if (!board) return
508
633
  const result = await board.applyPatch(event.detail)
509
634
  if (result?.missed) notify({ level: 'info', message: t('patch-missed', '{missed} of the changes could not be applied.', { missed: result.missed }) })
510
635
  }
511
636
 
512
- private onBoardRevert = (event: CustomEvent) => void this.surface?.board?.revertPatch?.(event.detail?.patchId)
637
+ private mayApplyOnScreen() {
638
+ if (this.openedFor === this.scopeKey(currentAssistantSurface()) && this.openedIdentity === this.identityKey()) return true
639
+ notify({ level: 'info', message: t('return-to-subject', 'Return to the conversation subject before applying this proposal.') })
640
+ return false
641
+ }
642
+
643
+ private onBoardRevert = (event: CustomEvent) => { if (this.mayApplyOnScreen()) void this.surface?.board?.revertPatch?.(event.detail?.patchId) }
513
644
 
514
- private onSceneAction = (event: CustomEvent) => void this.surface?.board?.applyAction?.(event.detail)
645
+ private onSceneAction = (event: CustomEvent) => { if (this.mayApplyOnScreen()) void this.surface?.board?.applyAction?.(event.detail) }
515
646
 
516
647
  /* ── drawing ─────────────────────────────────────────────────────────────────────── */
517
648
 
@@ -540,16 +671,16 @@ export class AssistantDock extends localize(i18next)(LitElement) {
540
671
  <md-icon>home</md-icon>
541
672
  </button>`
542
673
  : nothing}
543
- ${ready
674
+ ${ready && this.showingHistory
544
675
  ? html`<button
545
676
  class="sbtn"
546
677
  title=${this.showingHistory ? t('back-to-conversation', 'back to the conversation') : t('conversations', 'conversations')}
547
678
  @click=${() => (this.showingHistory = !this.showingHistory)}
548
679
  >
549
- <md-icon>${this.showingHistory ? 'forum' : 'list'}</md-icon>
680
+ <md-icon>arrow_back</md-icon>${t('back-to-conversation', 'back to the conversation')}
550
681
  </button>`
551
682
  : nothing}
552
- <button class="sbtn" title=${t('rebind', 'follow this screen again')} @click=${() => this.follow(true)}>
683
+ <button class="sbtn" title=${t('rebind', 'follow this screen again')} @click=${() => this.requestSurface(currentAssistantSurface())}>
553
684
  <md-icon>refresh</md-icon>
554
685
  </button>
555
686
  <button class="sbtn" title=${t('close', 'close')} @click=${() => closeOverlay(this.viewpart)}>
@@ -561,9 +692,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
561
692
 
562
693
  /** The home button: talk about the application instead of this screen, until the screen changes. */
563
694
  private useSurface(surface: AssistantSurface) {
564
- this.surface = surface
565
- this.chosenKind = ''
566
- void this.open()
695
+ void this.requestSurface(surface)
567
696
  }
568
697
 
569
698
  render(): TemplateResult {
@@ -578,6 +707,14 @@ export class AssistantDock extends localize(i18next)(LitElement) {
578
707
 
579
708
  return html`
580
709
  ${this.header()}
710
+ ${surface?.nextStep ? html`<div class="next-step" role="status">
711
+ <span>${surface.nextStep.description?.() || ''}</span>
712
+ <a href=${surface.nextStep.href}>${surface.nextStep.label()}<md-icon>arrow_forward</md-icon></a>
713
+ </div>` : nothing}
714
+ ${this.screenChanged ? html`<div class="transition" role="status">
715
+ ${t('screen-and-conversation', 'Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.', { screen: currentAssistantSurface()?.label() || t('no-subject', 'No subject'), conversation: surface?.label() || '' })}
716
+ <button class="sbtn" @click=${() => this.requestSurface(currentAssistantSurface())}>${t('switch-to-screen', 'Talk about this screen')}</button>
717
+ </div>` : this.navigationNotice ? html`<div class="transition" role="status">${this.navigationNotice}</div>` : nothing}
581
718
  ${ready && this.showingHistory
582
719
  ? html`<ox-assistant-session-manager
583
720
  .anchorType=${(anchor as any).anchorType}
@@ -585,6 +722,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
585
722
  .sessionId=${this.sessionId}
586
723
  .kind=${this.kind}
587
724
  .kindLabels=${this.kindLabels}
725
+ .scopeLabel=${t('conversation-scope', 'Conversations · {name}', { name: surface?.label() || '' })}
588
726
  @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId, event.detail.kind)}
589
727
  @assistant-session-reset=${() => this.newConversation()}
590
728
  @assistant-session-changed=${() => this.reloadSessions()}
@@ -597,21 +735,24 @@ export class AssistantDock extends localize(i18next)(LitElement) {
597
735
  .loading=${this.loading}
598
736
  .error=${this.failure}
599
737
  historyEnabled
738
+ singleConversation
600
739
  @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId)}
601
740
  @assistant-session-reset=${() => this.newConversation()}
602
741
  @assistant-session-history=${() => (this.showingHistory = true)}
603
742
  @assistant-session-retry=${() => this.open()}
604
743
  ></ox-assistant-session-toolbar>`
605
744
  : nothing}
606
- ${ready && !this.showingHistory && !this.loading && !this.failure
745
+ ${ready && !this.loading && !this.failure
607
746
  ? keyed(
608
747
  this.conversationView,
609
748
  html`<ox-assistant-chat
749
+ ?hidden=${this.showingHistory}
750
+ .sendBlocked=${this.screenChanged ? t('return-to-subject', 'Return to the conversation subject before sending or applying changes.') : ''}
610
751
  .sessionId=${this.sessionId}
611
752
  .prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
612
753
  .contextProvider=${() => {
613
754
  /* 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.'))
755
+ 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
756
  return { hostContext: surface?.hostContext?.() ?? {} }
616
757
  }}
617
758
  .mode=${mode}
@@ -631,6 +772,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
631
772
  .draftRequestId=${this.asking?.requestId}
632
773
  @assistant-mode-change=${this.onModeChange}
633
774
  @assistant-proposal-execute=${this.onProposal}
775
+ @assistant-proposal-check=${this.onProposalCheck}
634
776
  @scene-edit-patch=${this.onScenePatch}
635
777
  @board-edit-revert=${this.onBoardRevert}
636
778
  @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
+ })