@things-factory/ai-assistant 10.1.79 → 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 +3 -3
  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
@@ -47,11 +47,22 @@ 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
+ .actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
57
+ .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; }
58
+ .actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
59
+ .actions button.danger { color: var(--md-sys-color-error, #b42318); }
60
+ button:focus-visible, summary:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
50
61
  :host {
51
62
  display: flex;
52
63
  flex-direction: column;
53
64
  min-height: 0;
54
- font-size: 12.5px;
65
+ font-size: 14px;
55
66
  color: var(--md-sys-color-on-surface);
56
67
  }
57
68
 
@@ -192,11 +203,6 @@ export class AssistantSessionManager extends LitElement {
192
203
  gap: 2px;
193
204
  }
194
205
 
195
- .row:hover .acts,
196
- .row:focus-within .acts {
197
- display: flex;
198
- }
199
-
200
206
  button.icon {
201
207
  display: inline-flex;
202
208
  padding: 3px;
@@ -300,6 +306,7 @@ export class AssistantSessionManager extends LitElement {
300
306
  @property({ type: String }) anchorId = ''
301
307
  /** The conversation the dock has open - marked in the list. */
302
308
  @property({ type: String }) sessionId = ''
309
+ @property({ type: String }) scopeLabel = ''
303
310
  /** The doors. Replaceable for tests; a host normally leaves it. */
304
311
  @property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
305
312
  /** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
@@ -320,6 +327,22 @@ export class AssistantSessionManager extends LitElement {
320
327
  @state() private loading = false
321
328
  @state() private error = ''
322
329
  @state() private renamingId = ''
330
+ @state() private filtersOpen = false
331
+
332
+ private get hasFilters() {
333
+ return !!this.search.trim() || this.allKinds || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
334
+ }
335
+
336
+ private resetFilters() {
337
+ clearTimeout(this.searchTimer)
338
+ this.refilter(() => {
339
+ this.search = ''
340
+ this.allKinds = false
341
+ this.whose = 'all'
342
+ this.visibility = 'all'
343
+ this.includeHidden = this.unreadOnly = false
344
+ })
345
+ }
323
346
 
324
347
  private generation = 0
325
348
  private searchTimer?: ReturnType<typeof setTimeout>
@@ -461,17 +484,19 @@ export class AssistantSessionManager extends LitElement {
461
484
  const shown = this.entries.length
462
485
 
463
486
  return html`
487
+ <div class="scope-label">${this.scopeLabel}</div>
464
488
  <div class="bar">
465
489
  <input
466
490
  .value=${this.search}
467
- placeholder=${t('search', 'Search names and last messages')}
468
- aria-label=${t('search', 'Search names and last messages')}
491
+ placeholder=${t('search', 'Search conversations')}
492
+ aria-label=${t('search', 'Search conversations')}
469
493
  @input=${(e: Event) => this.onSearch((e.target as HTMLInputElement).value)}
470
- @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && this.onSearch('')}
494
+ @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape' && this.search) { e.stopPropagation(); this.onSearch('') } }}
471
495
  />
472
496
  <button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
473
497
  </div>
474
- <div class="filters">
498
+ <button class="filter-toggle" aria-expanded=${this.filtersOpen} @click=${() => (this.filtersOpen = !this.filtersOpen)}>${t('filters', 'Filters')}${this.hasFilters ? ' •' : ''}</button>
499
+ <div class="filters" ?hidden=${!this.filtersOpen}>
475
500
  ${this.kind
476
501
  ? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
477
502
  @change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
@@ -496,10 +521,12 @@ export class AssistantSessionManager extends LitElement {
496
521
  <label><input type="checkbox" .checked=${this.includeHidden}
497
522
  @change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
498
523
  </div>
524
+ ${this.hasFilters ? html`<button class="reset" @click=${() => this.resetFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
499
525
  <div class="list">
500
526
  ${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>`
527
+ ${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
528
+ ${shown === 0 && !this.loading && !this.error
529
+ ? 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
530
  : this.entries.map(entry => this.renderRow(entry))}
504
531
  ${shown < this.total
505
532
  ? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
@@ -528,7 +555,7 @@ export class AssistantSessionManager extends LitElement {
528
555
  aria-label=${t('rename', 'Rename')}
529
556
  @keydown=${(e: KeyboardEvent) => {
530
557
  if (e.key === 'Enter') this.rename(entry, (e.target as HTMLInputElement).value)
531
- if (e.key === 'Escape') this.renamingId = ''
558
+ if (e.key === 'Escape') { e.stopPropagation(); this.renamingId = '' }
532
559
  }}
533
560
  @blur=${(e: Event) => this.rename(entry, (e.target as HTMLInputElement).value)}
534
561
  />
@@ -537,28 +564,27 @@ export class AssistantSessionManager extends LitElement {
537
564
 
538
565
  return html`<div
539
566
  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 })}
541
567
  >
542
568
  <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>
569
+ <button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
570
+ <div class="title">${name}</div>
548
571
  <div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
549
- </span>
572
+ <span class="meta">${isPrivate ? t('audience-private', 'Participants only') : t('audience-open', 'Everyone in this domain')}${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>
573
+ </button>
574
+ <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() } }}>
575
+ <summary aria-label=${t('manage', 'Manage conversation')}>⋯</summary>
550
576
  <span class="acts" @click=${(e: Event) => e.stopPropagation()}>
551
577
  ${actions.rename
552
578
  ? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
553
- <md-icon>edit</md-icon>
579
+ ${t('rename', 'Rename')}
554
580
  </button>`
555
581
  : nothing}
556
582
  ${actions.setVisibility
557
583
  ? isPrivate
558
584
  ? 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>`
585
+ @click=${() => this.askOpen(entry, name)}>${t('make-open', 'Make public')}</button>`
560
586
  : 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>`
587
+ @click=${() => this.setVisibility(entry, 'private')}>${t('make-private', 'Make private')}</button>`
562
588
  : nothing}
563
589
  <button
564
590
  class="icon"
@@ -566,13 +592,14 @@ export class AssistantSessionManager extends LitElement {
566
592
  aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
567
593
  @click=${() => this.setHidden(entry, !entry.hidden)}
568
594
  >
569
- <md-icon>${actions.unhide ? 'visibility' : 'visibility_off'}</md-icon>
595
+ ${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
570
596
  </button>
571
597
  ${actions.remove
572
598
  ? 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>`
599
+ @click=${() => this.askRemove(entry, name)}>${t('remove', 'Delete')}</button>`
574
600
  : nothing}
575
601
  </span>
602
+ </details>
576
603
  </div>`
577
604
  }
578
605
 
@@ -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
+ }
@@ -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
  *