@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
@@ -47,11 +47,27 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
47
47
  @customElement('ox-assistant-session-manager')
48
48
  export class AssistantSessionManager extends LitElement {
49
49
  static styles = css`
50
+ .scope-label { padding: 12px 12px 0; font-size: 14px; font-weight: 600; }
51
+ .filter-toggle, .reset { margin: 6px 10px; padding: 7px; font: inherit; background: transparent; color: inherit; border: 1px solid var(--md-sys-color-outline-variant); border-radius: 6px; cursor: pointer; }
52
+ .filter-toggle { align-self: flex-end; }
53
+ .filters[hidden] { display: none; }
54
+ .open-row { display: block; flex: 1; min-width: 0; text-align: start; border: 0; background: transparent; color: inherit; font: inherit; padding: 4px; cursor: pointer; }
55
+ .meta { display: block; margin-top: 5px; font-size: 12px; color: var(--md-sys-color-on-surface-variant); }
56
+ /* Open and private must part at a glance - an open row is read by everyone in the domain (2026-09-30). */
57
+ .audience { display: inline-flex; align-items: center; gap: 3px; vertical-align: middle; }
58
+ .audience md-icon { --md-icon-size: 14px; }
59
+ .audience.open { padding: 1px 6px 1px 4px; border-radius: 6px; background: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); font-weight: 600; }
60
+ .row.open-audience { box-shadow: inset 3px 0 0 var(--md-sys-color-tertiary); }
61
+ .actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
62
+ .actions[open] .acts { display: flex; flex-direction: column; position: absolute; right: 10px; top: 40px; z-index: 2; min-width: 180px; padding: 6px; background: var(--md-sys-color-surface-container-lowest); border: 1px solid var(--md-sys-color-outline-variant); border-radius: 8px; box-shadow: 0 4px 16px #0002; }
63
+ .actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
64
+ .actions button.danger { color: var(--md-sys-color-error, #b42318); }
65
+ button:focus-visible, summary:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
50
66
  :host {
51
67
  display: flex;
52
68
  flex-direction: column;
53
69
  min-height: 0;
54
- font-size: 12.5px;
70
+ font-size: 14px;
55
71
  color: var(--md-sys-color-on-surface);
56
72
  }
57
73
 
@@ -192,11 +208,6 @@ export class AssistantSessionManager extends LitElement {
192
208
  gap: 2px;
193
209
  }
194
210
 
195
- .row:hover .acts,
196
- .row:focus-within .acts {
197
- display: flex;
198
- }
199
-
200
211
  button.icon {
201
212
  display: inline-flex;
202
213
  padding: 3px;
@@ -300,6 +311,7 @@ export class AssistantSessionManager extends LitElement {
300
311
  @property({ type: String }) anchorId = ''
301
312
  /** The conversation the dock has open - marked in the list. */
302
313
  @property({ type: String }) sessionId = ''
314
+ @property({ type: String }) scopeLabel = ''
303
315
  /** The doors. Replaceable for tests; a host normally leaves it. */
304
316
  @property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
305
317
  /** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
@@ -320,6 +332,22 @@ export class AssistantSessionManager extends LitElement {
320
332
  @state() private loading = false
321
333
  @state() private error = ''
322
334
  @state() private renamingId = ''
335
+ @state() private filtersOpen = false
336
+
337
+ private get hasFilters() {
338
+ return !!this.search.trim() || this.allKinds || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
339
+ }
340
+
341
+ private resetFilters() {
342
+ clearTimeout(this.searchTimer)
343
+ this.refilter(() => {
344
+ this.search = ''
345
+ this.allKinds = false
346
+ this.whose = 'all'
347
+ this.visibility = 'all'
348
+ this.includeHidden = this.unreadOnly = false
349
+ })
350
+ }
323
351
 
324
352
  private generation = 0
325
353
  private searchTimer?: ReturnType<typeof setTimeout>
@@ -461,17 +489,19 @@ export class AssistantSessionManager extends LitElement {
461
489
  const shown = this.entries.length
462
490
 
463
491
  return html`
492
+ <div class="scope-label">${this.scopeLabel}</div>
464
493
  <div class="bar">
465
494
  <input
466
495
  .value=${this.search}
467
- placeholder=${t('search', 'Search names and last messages')}
468
- aria-label=${t('search', 'Search names and last messages')}
496
+ placeholder=${t('search', 'Search conversations')}
497
+ aria-label=${t('search', 'Search conversations')}
469
498
  @input=${(e: Event) => this.onSearch((e.target as HTMLInputElement).value)}
470
- @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && this.onSearch('')}
499
+ @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape' && this.search) { e.stopPropagation(); this.onSearch('') } }}
471
500
  />
472
501
  <button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
473
502
  </div>
474
- <div class="filters">
503
+ <button class="filter-toggle" aria-expanded=${this.filtersOpen} @click=${() => (this.filtersOpen = !this.filtersOpen)}>${t('filters', 'Filters')}${this.hasFilters ? ' •' : ''}</button>
504
+ <div class="filters" ?hidden=${!this.filtersOpen}>
475
505
  ${this.kind
476
506
  ? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
477
507
  @change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
@@ -496,10 +526,12 @@ export class AssistantSessionManager extends LitElement {
496
526
  <label><input type="checkbox" .checked=${this.includeHidden}
497
527
  @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
498
528
  </div>
529
+ ${this.hasFilters ? html`<button class="reset" @click=${() => this.resetFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
499
530
  <div class="list">
500
531
  ${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
501
- ${shown === 0 && !this.loading
502
- ? html`<div class="empty">${this.search ? t('no-match', 'No conversation matches "{q}".', { q: this.search }) : t('none', 'No conversations yet.')}</div>`
532
+ ${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
533
+ ${shown === 0 && !this.loading && !this.error
534
+ ? html`<div class="empty" role="status">${this.hasFilters ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations for this subject yet.')}</div>`
503
535
  : this.entries.map(entry => this.renderRow(entry))}
504
536
  ${shown < this.total
505
537
  ? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
@@ -528,7 +560,7 @@ export class AssistantSessionManager extends LitElement {
528
560
  aria-label=${t('rename', 'Rename')}
529
561
  @keydown=${(e: KeyboardEvent) => {
530
562
  if (e.key === 'Enter') this.rename(entry, (e.target as HTMLInputElement).value)
531
- if (e.key === 'Escape') this.renamingId = ''
563
+ if (e.key === 'Escape') { e.stopPropagation(); this.renamingId = '' }
532
564
  }}
533
565
  @blur=${(e: Event) => this.rename(entry, (e.target as HTMLInputElement).value)}
534
566
  />
@@ -536,29 +568,28 @@ export class AssistantSessionManager extends LitElement {
536
568
  }
537
569
 
538
570
  return html`<div
539
- class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''}"
540
- @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}
571
+ class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''} ${isPrivate ? '' : 'open-audience'}"
541
572
  >
542
573
  <span class="dot"></span>
543
- <md-icon title=${isPrivate ? t('private', 'Private - only its participants read it') : t('open', 'Public - everyone in this domain reads it')}
544
- >${isPrivate ? 'lock' : 'public'}</md-icon
545
- >
546
- <span class="body">
547
- <div class="title" @dblclick=${(e: Event) => { e.stopPropagation(); if (actions.rename) this.renamingId = entry.id }}>${name}</div>
574
+ <button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
575
+ <div class="title">${name}</div>
548
576
  <div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
549
- </span>
577
+ <span class="meta"><span class="audience ${isPrivate ? 'private' : 'open'}"><md-icon aria-hidden="true">${isPrivate ? 'lock' : 'public'}</md-icon>${isPrivate ? t('audience-private', 'Participants only') : t('audience-open', 'Everyone in this domain')}</span>${entry.lastMessageAt && Number.isFinite(Date.parse(entry.lastMessageAt)) ? html` · <time datetime=${entry.lastMessageAt}>${new Intl.DateTimeFormat(i18next.language || 'en', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(entry.lastMessageAt))}</time>` : nothing}${entry.mine ? html` · ${t('filter-mine', 'Started by me')}` : nothing}</span>
578
+ </button>
579
+ <details class="actions" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); const details = e.currentTarget as HTMLDetailsElement; details.open = false; details.querySelector('summary')?.focus() } }}>
580
+ <summary aria-label=${t('manage', 'Manage conversation')}>⋯</summary>
550
581
  <span class="acts" @click=${(e: Event) => e.stopPropagation()}>
551
582
  ${actions.rename
552
583
  ? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
553
- <md-icon>edit</md-icon>
584
+ ${t('rename', 'Rename')}
554
585
  </button>`
555
586
  : nothing}
556
587
  ${actions.setVisibility
557
588
  ? isPrivate
558
589
  ? html`<button class="icon" title=${t('make-open', 'Make public')} aria-label=${t('make-open', 'Make public')}
559
- @click=${() => this.askOpen(entry, name)}><md-icon>public</md-icon></button>`
590
+ @click=${() => this.askOpen(entry, name)}>${t('make-open', 'Make public')}</button>`
560
591
  : html`<button class="icon" title=${t('make-private', 'Make private')} aria-label=${t('make-private', 'Make private')}
561
- @click=${() => this.setVisibility(entry, 'private')}><md-icon>lock</md-icon></button>`
592
+ @click=${() => this.setVisibility(entry, 'private')}>${t('make-private', 'Make private')}</button>`
562
593
  : nothing}
563
594
  <button
564
595
  class="icon"
@@ -566,13 +597,14 @@ export class AssistantSessionManager extends LitElement {
566
597
  aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
567
598
  @click=${() => this.setHidden(entry, !entry.hidden)}
568
599
  >
569
- <md-icon>${actions.unhide ? 'visibility' : 'visibility_off'}</md-icon>
600
+ ${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
570
601
  </button>
571
602
  ${actions.remove
572
603
  ? html`<button class="icon danger" title=${t('remove', 'Delete')} aria-label=${t('remove', 'Delete')}
573
- @click=${() => this.askRemove(entry, name)}><md-icon>delete</md-icon></button>`
604
+ @click=${() => this.askRemove(entry, name)}>${t('remove', 'Delete')}</button>`
574
605
  : nothing}
575
606
  </span>
607
+ </details>
576
608
  </div>`
577
609
  }
578
610
 
@@ -14,6 +14,7 @@ export class AssistantSessionToolbar extends LitElement {
14
14
  @property({ type: Boolean }) temporary = false
15
15
  @property() error = ''
16
16
  @property({ type: Boolean }) historyEnabled = false
17
+ @property({ type: Boolean }) singleConversation = false
17
18
  @property({ type: Number }) maxTabs = 5
18
19
  /** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */
19
20
  @property({ type: Boolean, reflect: true }) compact = false
@@ -50,7 +51,8 @@ export class AssistantSessionToolbar extends LitElement {
50
51
  const label = i18next.t('ai-assistant.session.history', { defaultValue: 'Conversation history' })
51
52
  return html`
52
53
  <div class="controls">
53
- ${this.temporary ? nothing : html`<div class="tabs" role="tablist" aria-label=${label}>
54
+ ${this.singleConversation ? html`<button @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.list', { defaultValue: 'Conversations' })}</button><span class="tabs">${this.sessions.find(session => session.id === this.sessionId)?.name || i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</span>` : nothing}
55
+ ${this.temporary || this.singleConversation ? nothing : html`<div class="tabs" role="tablist" aria-label=${label}>
54
56
  ${!this.sessionId ? html`<button role="tab" aria-selected="true">${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>` : nothing}
55
57
  ${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html`<button role="tab" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}
56
58
  ?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}
@@ -60,7 +62,7 @@ export class AssistantSessionToolbar extends LitElement {
60
62
  title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: 'Keep previous history and your work, and start a new conversation. Previous messages are excluded from subsequent requests.' })}
61
63
  @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>
62
64
  </div>
63
- ${this.historyEnabled ? html`<button class="history" ?disabled=${this.loading || this.disabled}
65
+ ${this.historyEnabled && !this.singleConversation ? html`<button class="history" ?disabled=${this.loading || this.disabled}
64
66
  @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: 'All History' })}</button>` : nothing}
65
67
  ${this.loading ? html`<p role="status">${i18next.t('ai-assistant.session.loading', { defaultValue: 'Loading conversation history…' })}</p>` : nothing}
66
68
  ${this.error ? html`<p role="alert">${this.error} <button ?disabled=${this.loading || this.disabled}
@@ -27,6 +27,8 @@ export interface RemoteMessage {
27
27
  content: string
28
28
  relatedPatchId?: string
29
29
  toolUsagesJson?: any
30
+ /** The answer's proposals, stored with it - the cards come back after a refresh (ADR-0097 Q-D4 ④). */
31
+ proposalsJson?: any
30
32
  /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */
31
33
  groundingWarningsJson?: any
32
34
  /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */
@@ -43,6 +45,7 @@ export function remoteMessageToLine(m: RemoteMessage): DedupLine {
43
45
  content: m.content,
44
46
  patchId: m.relatedPatchId ?? undefined,
45
47
  toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,
48
+ proposals: Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined,
46
49
  groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,
47
50
  /* Files sent with the turn (shared dock §9) - opened through the conversation's own route when drawn. */
48
51
  attachments: Array.isArray((m as any).attachmentsJson)
@@ -104,6 +107,7 @@ export function receiveRemoteMessage(
104
107
  content: m.content,
105
108
  patchId: m.relatedPatchId ?? nextLines[idx].patchId,
106
109
  toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,
110
+ proposals: nextLines[idx].proposals ?? (Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined),
107
111
  groundingWarnings: Array.isArray(m.groundingWarningsJson)
108
112
  ? m.groundingWarningsJson
109
113
  : nextLines[idx].groundingWarnings,
@@ -31,3 +31,39 @@ export function parseProposalOutcomeLine(content: string): ParsedProposalOutcome
31
31
  total: matched[5] === undefined ? undefined : Number(matched[5])
32
32
  }
33
33
  }
34
+
35
+ const REQUESTED = /^proposal (.+): requested$/
36
+
37
+ /** The key of a "requested" row (server `formatProposalRequestedLine`), or undefined for any other line. */
38
+ export function parseProposalRequestLine(content: string): string | undefined {
39
+ return REQUESTED.exec((content ?? '').trim())?.[1]
40
+ }
41
+
42
+ /**
43
+ * What a proposal card says, from the conversation's rows (ADR-0097 Q-D4 ④).
44
+ *
45
+ * ready nobody has taken it - the run button
46
+ * applying this page asked the host and no answer came yet - elapsed time, no button
47
+ * unconfirmed it was requested (here before a refresh, or in another window) and no answer is
48
+ * recorded - "check result", which asks the host what happened and never runs it again
49
+ * answered the host's verdict
50
+ *
51
+ * Time never turns `applying` into a failure (ADR-0070 A12-a decision ②): all that is known is that
52
+ * no answer came.
53
+ */
54
+ export type ProposalCardState =
55
+ | { state: 'ready' }
56
+ | { state: 'applying' }
57
+ | { state: 'unconfirmed' }
58
+ | { state: 'answered'; outcome: ParsedProposalOutcome }
59
+
60
+ export function proposalCardState(
61
+ key: string,
62
+ seen: { outcomes: ReadonlyMap<string, ParsedProposalOutcome>; requested: ReadonlySet<string>; sentHere: boolean }
63
+ ): ProposalCardState {
64
+ const outcome = seen.outcomes.get(key)
65
+ if (outcome) return { state: 'answered', outcome }
66
+ if (seen.sentHere) return { state: 'applying' }
67
+ if (seen.requested.has(key)) return { state: 'unconfirmed' }
68
+ return { state: 'ready' }
69
+ }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * ADR-0097 Q-D4 ④ — the chat asks the server before it asks the host, and a card that was requested and
3
+ * never answered offers "check result", never "run".
4
+ *
5
+ * The component itself needs a browser; the order of its steps is read from the source, and the rules
6
+ * it follows are tested on their own (`proposal-outcome.test.ts`).
7
+ */
8
+ import { readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ import { remoteMessageToLine } from './chat-echo-dedup'
12
+
13
+ const chat = readFileSync(join(__dirname, 'assistant-chat.ts'), 'utf8')
14
+ const dock = readFileSync(join(__dirname, 'assistant-dock.ts'), 'utf8')
15
+
16
+ const body = (source: string, start: string) => {
17
+ const from = source.indexOf(start)
18
+ expect(from).toBeGreaterThan(0)
19
+ return source.slice(from, source.indexOf('\n }\n', from))
20
+ }
21
+
22
+ it('★ the host is asked only after the server granted the request', () => {
23
+ const run = body(chat, 'private async executeProposal(')
24
+ const asked = run.indexOf('await this.requestProposal(key)')
25
+ const dispatched = run.indexOf("new CustomEvent('assistant-proposal-execute'")
26
+ expect(asked).toBeGreaterThan(0)
27
+ expect(dispatched).toBeGreaterThan(asked)
28
+ expect(run.slice(asked, dispatched)).toMatch(/if \(!granted\) \{[\s\S]*return\n/)
29
+ })
30
+
31
+ it('★ a requested card without an answer checks the result — it never runs again', () => {
32
+ const unconfirmed = chat.slice(chat.indexOf("card.state === 'unconfirmed'"), chat.indexOf(': html`', chat.indexOf("card.state === 'unconfirmed'")))
33
+ expect(unconfirmed).toMatch(/this\.checkProposal\(p, key\)/)
34
+ expect(unconfirmed).not.toMatch(/executeProposal/)
35
+ })
36
+
37
+ it('★ "check result" reports only what the host knows, and runs nothing', () => {
38
+ const check = body(dock, 'private onProposalCheck = async')
39
+ expect(check).toMatch(/this\.surface\?\.proposalOutcome/)
40
+ expect(check).toMatch(/if \(!outcome\?\.verdict\) \{[\s\S]*return/)
41
+ expect(check).not.toMatch(/runProposal|assistant-proposal-execute/)
42
+ })
43
+
44
+ it('★ a stored answer brings its cards back after a refresh', () => {
45
+ const line: any = remoteMessageToLine({ id: 'm1', role: 'assistant', content: 'here', proposalsJson: [{ tool: 'proposeFigureCandidate', key: 'k1' }] })
46
+ expect(line.proposals).toEqual([{ tool: 'proposeFigureCandidate', key: 'k1' }])
47
+ expect((remoteMessageToLine({ id: 'm2', role: 'assistant', content: 'plain', proposalsJson: [] }) as any).proposals).toBeUndefined()
48
+ })
49
+
50
+ it('the chat reads stored proposals from history and from the broadcast', () => {
51
+ expect(chat.split('proposalsJson').length - 1).toBeGreaterThanOrEqual(2)
52
+ })
@@ -0,0 +1,38 @@
1
+ import { surfaceTransition } from './assistant-navigation.js'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ describe('a screen offers context without taking over an active conversation', () => {
6
+ test.each([false, true])('a different identity resets even when active=%s', active => {
7
+ for (const sameSubject of [false, true]) {
8
+ expect(surfaceTransition({ identityChanged: true, sameSubject, active })).toBe('reset')
9
+ }
10
+ })
11
+ test('an empty conversation follows the screen', () => {
12
+ expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: false })).toBe('follow')
13
+ })
14
+ test('an existing conversation, draft or ongoing response offers a switch', () => {
15
+ expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: true })).toBe('offer')
16
+ })
17
+ test('returning to the same subject refreshes without reopening', () => {
18
+ expect(surfaceTransition({ identityChanged: false, sameSubject: true, active: true })).toBe('refresh')
19
+ })
20
+ })
21
+
22
+ describe('navigation wiring', () => {
23
+ const dock = readFileSync(join(__dirname, '../components/assistant-dock.ts'), 'utf8')
24
+ test('history hides rather than unmounts the chat and its unsent input', () => {
25
+ expect(dock).toContain('?hidden=${this.showingHistory}')
26
+ expect(dock).toContain('ready && !this.loading && !this.failure')
27
+ })
28
+ test('identity changes clear a carried ask and reread capability', () => {
29
+ expect(dock).toContain("if (transition === 'reset')")
30
+ expect(dock).toContain('this.asking = undefined')
31
+ expect(dock).toContain("this.capability = 'unknown'")
32
+ })
33
+ test('all direct board actions check the current screen, not the retained editor', () => {
34
+ expect(dock).toContain('if (!this.mayApplyOnScreen()) return')
35
+ expect(dock.match(/if \(this.mayApplyOnScreen\(\)\)/g)).toHaveLength(2)
36
+ expect(dock).toContain("errorCode: 'conversation-screen-changed'")
37
+ })
38
+ })
@@ -0,0 +1,6 @@
1
+ /** Screen changes offer a new subject; identity changes must never carry the old conversation. */
2
+ export function surfaceTransition(input: { identityChanged: boolean; sameSubject: boolean; active: boolean }): 'reset' | 'refresh' | 'offer' | 'follow' {
3
+ if (input.identityChanged) return 'reset'
4
+ if (input.sameSubject) return 'refresh'
5
+ return input.active ? 'offer' : 'follow'
6
+ }
@@ -11,6 +11,8 @@ export interface AssistantSession {
11
11
  * 칸이 서기 전에 난 세션에는 값이 없다. 그때는 지금 대화로 두고, 서버가 다음 턴에 찍는다.
12
12
  */
13
13
  kind?: string
14
+ /** Who reads it: 'open' (everyone in the domain) or 'private' (participants). The dock draws an open one apart. */
15
+ visibility?: string
14
16
  }
15
17
  export interface AssistantSessionTarget {
16
18
  /** Account/domain boundary supplied by the authenticated host. */
@@ -20,7 +20,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
20
20
  * Every readable conversation of this kind on the anchor, not only mine - the same scope the manager
21
21
  * shows by default (design/ai/shared-dock.md §2-6). Without a kind: every kind, as before.
22
22
  */
23
- query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind}}`,
23
+ query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind visibility}}`,
24
24
  variables: { type: target.anchorType, id: target.anchorId, kind: target.kind || null }, fetchPolicy: 'no-cache'
25
25
  })
26
26
  if (result.errors?.length) throw new Error(result.errors[0].message)
@@ -36,7 +36,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
36
36
  },
37
37
  async create(target) {
38
38
  const result = await client.mutate({
39
- mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name kind}}`,
39
+ mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name kind visibility}}`,
40
40
  variables: { type: target.anchorType, id: target.anchorId }
41
41
  })
42
42
  if (result.errors?.length) throw new Error(result.errors[0].message)
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { store } from '@operato/shell'
15
15
 
16
+ import type { ProposalOutcome } from './assistant-proposals.js'
17
+
16
18
  /** The row a conversation hangs on - its session anchor (ADR-0072 decision 6). */
17
19
  export interface AssistantAnchor {
18
20
  anchorType: string
@@ -27,6 +29,16 @@ export interface AssistantStuck {
27
29
 
28
30
  export type AnchorResult = AssistantAnchor | AssistantStuck
29
31
 
32
+ /** A route the person can take from a surface when the next useful action lives in the app. */
33
+ export interface AssistantSurfaceNextStep {
34
+ /** The small, concrete action shown below the dock header. */
35
+ label(): string
36
+ /** A route inside the current application. */
37
+ href: string
38
+ /** Why this is the useful next action, in the person's words. */
39
+ description?(): string
40
+ }
41
+
30
42
  export function isAnchor(result: AnchorResult | undefined): result is AssistantAnchor {
31
43
  return !!result && 'anchorId' in result && !!result.anchorId
32
44
  }
@@ -58,11 +70,20 @@ export interface AssistantSurface {
58
70
  label(): string
59
71
  detail?(): string
60
72
  icon?: string
73
+ /** An app-owned route that helps the person make this surface useful (for example, create the first Twin). */
74
+ nextStep?: AssistantSurfaceNextStep
61
75
  /** Read at the moment of sending - never cached, so a draft cannot go stale. */
62
76
  hostContext?(): Record<string, unknown>
63
77
  board?: BoardSurface
64
78
  /** The dock opened another conversation for this surface - e.g. the board modeller records its direct edits under it. */
65
79
  sessionChanged?(sessionId: string): void
80
+ /**
81
+ * What became of a proposal whose outcome never reached the conversation (ADR-0097 Q-D4 ④) - asked by
82
+ * "check result", never to run it again. Answer from the host's own record, keyed by the proposal's key.
83
+ * `undefined` when the host cannot tell (not saved, undone, never applied): the card stays "no answer
84
+ * received" - an unknown result is not a refusal.
85
+ */
86
+ proposalOutcome?(proposalKey: string, proposal: any): Promise<ProposalOutcome | undefined> | ProposalOutcome | undefined
66
87
  }
67
88
 
68
89
  type Listener = () => void
@@ -273,6 +273,13 @@ export declare class OxAssistantChat extends LitElement {
273
273
  /** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
274
274
  private foldNoticeDismissed;
275
275
  private busy;
276
+ /** Host navigation may retain a conversation for reading while its subject is off screen. */
277
+ sendBlocked: string;
278
+ /** The host may inspect navigation risk without reading or copying the person's input. */
279
+ get navigationState(): {
280
+ busy: boolean;
281
+ draft: boolean;
282
+ };
276
283
  private errorMessage?;
277
284
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
278
285
  private participants;
@@ -561,6 +568,14 @@ export declare class OxAssistantChat extends LitElement {
561
568
  private proposalSentAt;
562
569
  private elapsedTimer?;
563
570
  private executeProposal;
571
+ private requestProposal;
572
+ /**
573
+ * "Check result" on a card that was requested and never answered: the host is asked what happened
574
+ * (`AssistantSurface.proposalOutcome`), never to run it again. The dock reports what it learns.
575
+ */
576
+ private checkProposal;
577
+ /** Keys whose run was requested - from the conversation's rows, like the outcomes. */
578
+ private get proposalRequests();
564
579
  /**
565
580
  * 호스트가 무엇을 했는지 — 대화에 남은 `system` 행에서 읽는다(ADR-0070 A12-a 결정 ①).
566
581
  *