@things-factory/ai-assistant 10.1.157 → 10.1.160

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 (75) hide show
  1. package/client/components/assistant-chat.ts +25 -213
  2. package/client/components/assistant-dock.ts +11 -32
  3. package/client/components/assistant-mode.test.ts +1 -43
  4. package/client/components/assistant-mode.ts +0 -47
  5. package/client/utils/assistant-surface.ts +1 -1
  6. package/client/utils/composer-frame.ts +6 -4
  7. package/dist-client/components/assistant-chat.d.ts +5 -69
  8. package/dist-client/components/assistant-chat.js +25 -205
  9. package/dist-client/components/assistant-chat.js.map +1 -1
  10. package/dist-client/components/assistant-dock.d.ts +0 -3
  11. package/dist-client/components/assistant-dock.js +10 -34
  12. package/dist-client/components/assistant-dock.js.map +1 -1
  13. package/dist-client/components/assistant-mode.d.ts +0 -33
  14. package/dist-client/components/assistant-mode.js +0 -37
  15. package/dist-client/components/assistant-mode.js.map +1 -1
  16. package/dist-client/components/assistant-mode.test.js +1 -35
  17. package/dist-client/components/assistant-mode.test.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-client/utils/assistant-surface.d.ts +1 -1
  20. package/dist-client/utils/assistant-surface.js.map +1 -1
  21. package/dist-client/utils/composer-frame.d.ts +5 -3
  22. package/dist-client/utils/composer-frame.js +4 -4
  23. package/dist-client/utils/composer-frame.js.map +1 -1
  24. package/dist-server/index.d.ts +1 -0
  25. package/dist-server/index.js +2 -0
  26. package/dist-server/index.js.map +1 -1
  27. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  28. package/dist-server/service/assistant-chat-resolver.js +30 -6
  29. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  30. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -2
  31. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +13 -24
  32. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  33. package/dist-server/service/assistant-mode/fallback-subjects.d.ts +1 -0
  34. package/dist-server/service/assistant-mode/fallback-subjects.js +37 -0
  35. package/dist-server/service/assistant-mode/fallback-subjects.js.map +1 -0
  36. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  37. package/dist-server/service/assistant-mode/index.js +7 -2
  38. package/dist-server/service/assistant-mode/index.js.map +1 -1
  39. package/dist-server/service/assistant-mode/registry.d.ts +2 -6
  40. package/dist-server/service/assistant-mode/registry.js +16 -56
  41. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  42. package/dist-server/service/assistant-mode/resolve.js +3 -1
  43. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  44. package/dist-server/service/assistant-mode/screen-rules.d.ts +25 -0
  45. package/dist-server/service/assistant-mode/screen-rules.js +47 -0
  46. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -0
  47. package/dist-server/service/assistant-mode/types.d.ts +8 -25
  48. package/dist-server/service/assistant-mode/types.js.map +1 -1
  49. package/dist-server/service/testing/mode-conformance.d.ts +11 -0
  50. package/dist-server/service/testing/mode-conformance.js +28 -0
  51. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  52. package/dist-server/tool-registry-boot-report.d.ts +1 -1
  53. package/dist-server/tool-registry-boot-report.js +16 -7
  54. package/dist-server/tool-registry-boot-report.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +6 -6
  57. package/server/index.ts +2 -0
  58. package/server/service/assistant-chat-resolver.ts +33 -7
  59. package/server/service/assistant-mode/assistant-mode-resolver.ts +12 -25
  60. package/server/service/assistant-mode/fallback-subjects.ts +36 -0
  61. package/server/service/assistant-mode/index.ts +2 -1
  62. package/server/service/assistant-mode/one-conversation-per-subject.test.ts +37 -0
  63. package/server/service/assistant-mode/open-subject-door.test.ts +29 -0
  64. package/server/service/assistant-mode/registry.ts +14 -55
  65. package/server/service/assistant-mode/resolve.test.ts +10 -1
  66. package/server/service/assistant-mode/resolve.ts +3 -1
  67. package/server/service/assistant-mode/screen-rules.test.ts +39 -0
  68. package/server/service/assistant-mode/screen-rules.ts +47 -0
  69. package/server/service/assistant-mode/types.ts +8 -31
  70. package/server/service/testing/mode-conformance.ts +27 -1
  71. package/server/tool-registry-boot-report.test.ts +14 -1
  72. package/server/tool-registry-boot-report.ts +16 -7
  73. package/test/assistant-dock.test.ts +27 -4
  74. package/test/composer-frame.test.ts +12 -2
  75. package/server/service/assistant-mode/subject-defaults.test.ts +0 -86
@@ -68,7 +68,7 @@ import {
68
68
  * 뜨지 않는 것이 맞다.
69
69
  */
70
70
  import { chatDefaults } from './chat-defaults.js'
71
- import { isDifferentConversation, resolveConversation, type AssistantMode, type AssistantModeView, pickInitialMode } from './assistant-mode.js'
71
+ import { isDifferentConversation, resolveConversation, type AssistantMode, type AssistantModeView } from './assistant-mode.js'
72
72
  import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState, type ParsedProposalOutcome } from './proposal-outcome-line.js'
73
73
  import './entry-review.js'
74
74
  import { entryGroupOf, entryReadyCount, entryTitle, isChangeProposal, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
@@ -161,6 +161,7 @@ const BOARD_AI_CHAT_MUTATION = gql`
161
161
  mutation BoardAIChat($input: BoardAIChatInput!) {
162
162
  boardAIChat(input: $input) {
163
163
  reply
164
+ suggestion
164
165
  patch
165
166
  followUp
166
167
  clientId
@@ -201,6 +202,7 @@ const ASSISTANT_CHAT_MUTATION = gql`
201
202
  mutation AssistantChat($input: AssistantChatInput!) {
202
203
  assistantChat(input: $input) {
203
204
  reply
205
+ suggestion
204
206
  clientId
205
207
  sessionId
206
208
  userMessageId
@@ -221,21 +223,6 @@ const ASSISTANT_CHAT_MUTATION = gql`
221
223
  * (ADR-0061 decision 7) and translated there, so the panel and the door cannot describe different
222
224
  * conversations, and a host does not carry a second copy of the words.
223
225
  */
224
- /*
225
- * 이 화면에 설 수 있는 대화들 — ADR-0072 A17 ①.
226
- *
227
- * 화면은 자기가 무엇을 보여 주는지만 말하고, 그 위에 어느 대화가 서는지는 앱 서버가 답한다.
228
- */
229
- const ASSISTANT_MODES_QUERY = gql`
230
- query AssistantModes($subject: String!) {
231
- assistantModes(subject: $subject) {
232
- kind
233
- label
234
- isDefault
235
- }
236
- }
237
- `
238
-
239
226
  const AI_AGENT_CHOICES_QUERY = gql`
240
227
  query AiAgentChoices {
241
228
  aiAgentChoices {
@@ -500,13 +487,8 @@ export class OxAssistantChat extends LitElement {
500
487
  mode?: AssistantMode
501
488
 
502
489
  /**
503
- * 이 화면이 보여 주는 것의 **종류** — `'board'`, `'space'`, `'station'`, `'figure'` (ADR-0072 A17 ①).
504
- *
505
- * `mode` 가 「지금 어느 대화인가」라면 이것은 「그 자리에 어떤 대화들이 설 수 있나」이다. 서버가
506
- * `assistantModes(subject)` 로 답하고, 둘 이상이면 고르개가 뜬다. 하나면 고르개를 내지 않는다 —
507
- * 고를 것이 없는 고르개는 자리만 차지한다.
508
- *
509
- * 화면이 이것을 안 주면 고르개는 없고 host 가 정한 대화 하나만 산다. 그것이 오늘까지의 모양이다.
490
+ * 이 화면이 보여 주는 것의 **종류** — `'board'`, `'space'`, `'station'`, `'figure'`. 화면에 대화는 하나다
491
+ * (design/ai/screen-pattern.md §3). 바뀌면 사업장의 AI 에이전트 목록을 다시 읽는다.
510
492
  */
511
493
  @property({ type: String })
512
494
  subject?: string
@@ -1053,11 +1035,6 @@ export class OxAssistantChat extends LitElement {
1053
1035
 
1054
1036
  private modeViewToken = 0
1055
1037
 
1056
- /** 이 subject 에 설 수 있는 대화들 — 기본이 먼저. 하나뿐이면 고르개를 그리지 않는다. */
1057
- @state() private modeChoices: { kind: string; label: string; isDefault: boolean }[] = []
1058
-
1059
- private modeChoicesToken = 0
1060
-
1061
1038
  /** The AI agents this domain lets a person pick (design/ai/agent-settings.md §5). One or none draws no picker. */
1062
1039
  @state() private agentChoices: AgentChoice[] = []
1063
1040
  @state() private agentId?: string
@@ -1092,80 +1069,6 @@ export class OxAssistantChat extends LitElement {
1092
1069
  rememberAgent(this.contextPath, agentId)
1093
1070
  }
1094
1071
 
1095
- /**
1096
- * 이 화면에 설 수 있는 대화를 서버에 묻는다.
1097
- *
1098
- * 화면이 종류 목록을 손에 들고 있으면, 대화를 하나 더 세운 날 그 화면만 모른 채로 남는다. 목록은
1099
- * 앱 서버의 선언에서 나온다(ADR-0072 A17 ①).
1100
- */
1101
- private async loadModeChoices() {
1102
- const subject = this.subject
1103
- if (!subject) {
1104
- this.modeChoices = []
1105
- return
1106
- }
1107
- const token = ++this.modeChoicesToken
1108
- try {
1109
- const result = await client.query({
1110
- query: ASSISTANT_MODES_QUERY,
1111
- variables: { subject },
1112
- fetchPolicy: 'cache-first'
1113
- })
1114
- if (token !== this.modeChoicesToken) return
1115
- this.modeChoices = result.data?.assistantModes ?? []
1116
- } catch {
1117
- /*
1118
- * 못 물어봤다고 화면을 막지 않는다 — host 가 준 대화는 그대로 산다. 고르개만 안 나온다.
1119
- * 여기서 오류 문구를 띄우면 고를 것이 하나뿐인 화면에서도 빨간 줄이 뜬다.
1120
- */
1121
- if (token !== this.modeChoicesToken) return
1122
- this.modeChoices = []
1123
- }
1124
- }
1125
-
1126
- /**
1127
- * 사람이 다른 대화를 골랐다.
1128
- *
1129
- * 고른 것은 **이 subject 종류에 대해** 기억한다 — 보드를 열 때마다 저작 대화로 돌아가면, 현장을
1130
- * 물으려던 사람은 화면을 열 때마다 같은 손질을 다시 해야 한다. 기억은 이 브라우저에만 남는다
1131
- * (서버에 칸을 만들지 않는다 — A17 ①). 세션 자체는 자기가 어느 대화였는지 이미 안다.
1132
- */
1133
- private onModePick(kind: string) {
1134
- if (!this.mode || kind === this.mode.kind) return
1135
- try {
1136
- if (this.subject) localStorage.setItem(`assistant-mode:${this.subject}`, kind)
1137
- } catch {
1138
- /* 저장이 막힌 브라우저에서도 고르기 자체는 된다 — 다음에 다시 고르면 될 뿐이다. */
1139
- }
1140
- this.askHostForMode(kind)
1141
- }
1142
-
1143
- /**
1144
- * **고르는 것은 청하는 일이다** — `mode` 를 여기서 바꾸지 않는다.
1145
- *
1146
- * host 는 렌더마다 `mode` 를 다시 내려 준다. 패널이 자기 손으로 고쳐 두면 다음 렌더가 그것을 덮고,
1147
- * 더 나쁜 것은 host 가 **자기가 무엇을 열었는지 모르는 채로 남는다**는 것이다. host 는 대화의 세션을
1148
- * 대상별로 들고 있으므로, 대화가 바뀐 것을 모르면 앞 대화의 세션을 그대로 넘긴다 — 그러면 새 말이 앞
1149
- * 대화에 붙는다(2026-09-18 Figure 레인이 :3300 에서 본 것이 이것이다).
1150
- *
1151
- * 그래서 주인은 하나다: host 가 `mode` 를 갖고, 패널은 「이것으로 바꿔 달라」고 말한다. 기억은 패널이
1152
- * 진다(브라우저마다 다른 값이라 host 가 질 것이 아니다) — 기억한 것을 되살릴 때도 같은 청으로 나간다.
1153
- */
1154
- private askHostForMode(kind: string) {
1155
- this.dispatchEvent(
1156
- new CustomEvent('assistant-mode-change', { detail: { kind, subject: this.subject }, bubbles: true, composed: true })
1157
- )
1158
- }
1159
-
1160
- /** 이 subject 에서 지난번에 고른 대화 — 없으면 undefined. */
1161
- private rememberedMode(subject: string): string | undefined {
1162
- try {
1163
- return localStorage.getItem(`assistant-mode:${subject}`) ?? undefined
1164
- } catch {
1165
- return undefined
1166
- }
1167
- }
1168
-
1169
1072
  private async loadModeView() {
1170
1073
  const mode = this.mode
1171
1074
  if (!mode?.kind) {
@@ -1239,31 +1142,8 @@ export class OxAssistantChat extends LitElement {
1239
1142
  background: var(--md-sys-color-surface, #ffffff);
1240
1143
  }
1241
1144
 
1242
- /* ── C4 Roster (participants + presence) ───────────── */
1243
- /* 이 대화의 주어 — 같은 화면 위에 두 대화가 설 때만 나온다(ADR-0072 A17 ①).
1244
- 지금 대화가 제목 굵기로 서고, 바꿀 자리가 그 옆에 붙는다. 세션 탭과 같은 무게로 그리지 않는다
1245
- (UX 레인, 2026-09-18) — 한쪽은 화제를 바꾸고 한쪽은 실타래를 바꾼다. */
1246
- .mode-subject {
1247
- display: flex;
1248
- align-items: baseline;
1249
- gap: 8px;
1250
- padding: 6px 12px;
1251
- border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
1252
- }
1253
- .mode-now {
1254
- /* 고른 상태의 색은 세션 탭과 같은 계열이다 — 한 패널 안에서 「골랐다」가 두 색으로 나오면 읽는
1255
- 사람은 두 색이 서로 다른 뜻인 줄 안다.
1256
-
1257
- 12px 이었다가 한 단 내렸다. 도크 제목(figure 의 .scope-title 이 12.5px / 650)과 0.5px ·
1258
- 굵기 50 차이는 눈에 닿지 않아서, 거의 같은 제목 줄이 둘 포개졌다. 도크 제목은 그 도크 전체를
1259
- 거느리고 이 줄은 그 안 대화 하나의 성질이다 — 11px 이면 이 패널의 다른 조작부와 같은 단이고,
1260
- 「이것이 주어다」는 색이 계속 진다(UX 레인, 2026-09-18). */
1261
- color: var(--primary-color, #007f87);
1262
- font-size: 11px;
1263
- font-weight: 600;
1264
- }
1265
- .mode-switch,
1266
- .mode-select {
1145
+ /* The agent sits at the end of the row above the box. */
1146
+ .agent-select {
1267
1147
  appearance: none;
1268
1148
  background: transparent;
1269
1149
  border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1);
@@ -1274,24 +1154,12 @@ export class OxAssistantChat extends LitElement {
1274
1154
  font-size: 11px;
1275
1155
  padding: 2px 10px;
1276
1156
  white-space: nowrap;
1277
- }
1278
- /* The agent sits at the end of the row above the box. */
1279
- .agent-select {
1280
1157
  margin-left: auto;
1281
1158
  max-width: 50%;
1282
1159
  text-overflow: ellipsis;
1283
1160
  }
1284
- .mode-switch:hover:not([disabled]) {
1285
- background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent);
1286
- color: var(--primary-color, #007f87);
1287
- }
1288
- /* 답하는 동안에도 「내가 지금 무엇에 대해 말하고 있나」는 또렷해야 한다 — 흐려지는 것은 바꿀
1289
- 자리뿐이다. 지금 대화까지 흐리면 그 한 줄이 가장 안 보인다. */
1290
- .mode-subject [disabled] {
1291
- cursor: default;
1292
- opacity: 0.5;
1293
- }
1294
1161
 
1162
+ /* ── C4 Roster (participants + presence) ───────────── */
1295
1163
  .roster {
1296
1164
  display: flex;
1297
1165
  align-items: center;
@@ -2984,22 +2852,14 @@ export class OxAssistantChat extends LitElement {
2984
2852
  }
2985
2853
  }
2986
2854
 
2987
- /**
2988
- * 화면이 보여 주는 것의 종류가 바뀌었다 — 설 수 있는 대화를 다시 묻고, 지난번 고른 것을 되살린다.
2989
- *
2990
- * 되살리는 것은 **고른 대화**이지 세션이 아니다. 세션은 자기가 어느 대화였는지 이미 알고, 다른
2991
- * 대화로 가면 그때 새로 난다(ADR-0072 A17 ①).
2992
- */
2993
- private async onSubjectChanged() {
2855
+ /** 화면이 보여 주는 것의 종류가 바뀌었다 — 사업장의 AI 에이전트 목록을 다시 읽는다. */
2856
+ private onSubjectChanged() {
2994
2857
  void this.loadAgentChoices()
2995
- await this.loadModeChoices()
2996
- if (this.modeChoices.length < 2 || !this.subject || !this.mode) return
2997
-
2998
- const kind = pickInitialMode(this.mode.kind, this.modeChoices, this.rememberedMode(this.subject))
2999
- if (kind && kind !== this.mode.kind) this.askHostForMode(kind)
3000
2858
  }
3001
2859
 
3002
2860
  updated(changed: Map<string, any>) {
2861
+ /* Another conversation has no answer of this one to follow. */
2862
+ if (changed.has('sessionId') && changed.get('sessionId') !== undefined) this.nextRequest = undefined
3003
2863
  if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
3004
2864
 
3005
2865
  /*
@@ -3235,7 +3095,6 @@ export class OxAssistantChat extends LitElement {
3235
3095
  ${this.toastMessage
3236
3096
  ? html`<div class="toast show">${this.toastMessage}</div>`
3237
3097
  : nothing}
3238
- ${this.renderModeSubject()}
3239
3098
  ${this.renderSessionTabs()}
3240
3099
  ${this.renderRoster()}
3241
3100
  <div class="messages ${this.composerWide || this.previewedTable ? 'covered' : ''}" @click=${this.onBubbleClick}>
@@ -3780,9 +3639,17 @@ export class OxAssistantChat extends LitElement {
3780
3639
 
3781
3640
  /** The request a Tab writes into the empty box - `composerSuggestion` decides it. */
3782
3641
  private get suggestion(): string | undefined {
3783
- return composerSuggestion({ started: this.lines.length > 0, modeExamples: this.modeView?.examples, hostExamples: this.examples })
3642
+ return composerSuggestion({
3643
+ started: this.lines.length > 0,
3644
+ modeExamples: this.modeView?.examples,
3645
+ hostExamples: this.examples,
3646
+ answered: this.nextRequest
3647
+ })
3784
3648
  }
3785
3649
 
3650
+ /** The next request the last answer named - gone once the person sends or the conversation changes. */
3651
+ @state() private nextRequest?: string
3652
+
3786
3653
  /** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
3787
3654
  * big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */
3788
3655
  private onTextareaInput(e: Event) {
@@ -4112,33 +3979,6 @@ export class OxAssistantChat extends LitElement {
4112
3979
 
4113
3980
  /** 보드의 세션 탭 — 다중 세션 활성화 시 패널 상단. host 가 sessions 비워두면 표시 X.
4114
3981
  * 레이블은 session.name 우선, 없으면 fallback "세션 N". */
4115
- /**
4116
- * 이 대화의 **주어** — 같은 화면 위에 두 대화가 설 때만 나온다(ADR-0072 A17 ①).
4117
- *
4118
- * 하나뿐이면 아무것도 그리지 않는다. 고를 것이 없는 고르개는 자리만 차지하고, 사람에게 「무언가
4119
- * 고를 것이 있다」는 잘못된 신호를 준다.
4120
- *
4121
- * ── 왜 탭 줄이 아닌가 (UX 레인, 2026-09-18) ───────────────────────────────
4122
- * 처음에는 칩 줄에 `role="tablist"` 를 달았다. 바로 아래가 세션 탭이고 그쪽도 tablist 라서, 화면
4123
- * 낭독기로 들으면 「탭 목록, 대화」 다음에 「탭 목록, Conversation history」 가 이어졌다 — 고르는
4124
- * 축이 둘인데 둘 다 「탭」이라고 말한다. 게다가 ARIA 탭은 좌우 화살표 이동과 `tabpanel` 이 함께
4125
- * 있어야 성립하고, 둘 다 없으면 낭독기는 「2 중 1번째 탭」이라 말하고 화살표는 아무 일도 안 한다.
4126
- *
4127
- * 무게도 다르다. 세션은 「어느 지난 대화냐」이고 모드는 「이 대화가 무엇에 대한 것이냐」이다. 같은
4128
- * 무게로 그리면 어느 쪽이 화제를 바꾸고 어느 쪽이 실타래를 바꾸는지 구별되지 않는다. 그래서 지금
4129
- * 대화가 제목 굵기로 서고, 바꿀 자리는 그 옆에 붙는다.
4130
- *
4131
- * ── 왜 도크 제목 줄로 안 올리나 (UX 레인 판정, 2026-09-18) ────────────────
4132
- * 도크 제목은 「지금 어디에 있나」(이 도형 · 이 현장 · 이 스테이션 화면)이고 이 줄은 「이 대화가
4133
- * 무엇에 대한 것이냐」이다. 대화의 주어는 대화 안에 있다. 그리고 세 도크가 각자 그리면 세 벌이 되고
4134
- * 그 세 벌은 갈린다 — `modeChoices` 를 아는 것도 여기다. 올리면 목록과 고른 것이 세 자리로 나갔다
4135
- * 돌아온다.
4136
- */
4137
- private renderModeSubject() {
4138
- if (this.modeChoices.length < 2) return nothing
4139
- return html`<div class="mode-subject">${this.renderModePicker()}</div>`
4140
- }
4141
-
4142
3982
  /**
4143
3983
  * Which AI answers the next turn - at the end of the row above the box, where the words about writing stand. A row of
4144
3984
  * its own spent a line of the dock on one control. It is a property of the turn, not of the conversation: switching
@@ -4149,7 +3989,7 @@ export class OxAssistantChat extends LitElement {
4149
3989
  const current = this.agentChoices.find(one => one.id === this.agentId)
4150
3990
  return html`
4151
3991
  <select
4152
- class="mode-select agent-select"
3992
+ class="agent-select"
4153
3993
  aria-label=${i18next.t('ai-assistant.label.ai-agent', { defaultValue: 'AI agent' })}
4154
3994
  title=${current ? agentDetail(current) : ''}
4155
3995
  ?disabled=${this.busy}
@@ -4159,37 +3999,6 @@ export class OxAssistantChat extends LitElement {
4159
3999
  `
4160
4000
  }
4161
4001
 
4162
- private renderModePicker() {
4163
- const current = this.modeChoices.find(choice => choice.kind === this.mode?.kind)
4164
- const others = this.modeChoices.filter(choice => choice.kind !== this.mode?.kind)
4165
-
4166
- return html`
4167
- <span class="mode-now">${current?.label ?? this.mode?.kind ?? ''}</span>
4168
- ${others.length === 1
4169
- ? html`
4170
- <button
4171
- class="mode-switch"
4172
- title=${i18next.t('ai-assistant.text.switch-conversation', { name: others[0].label })}
4173
- aria-label=${i18next.t('ai-assistant.text.switch-conversation', { name: others[0].label })}
4174
- ?disabled=${this.busy}
4175
- @click=${() => this.onModePick(others[0].kind)}>
4176
- ${others[0].label}
4177
- </button>
4178
- `
4179
- : html`
4180
- <select
4181
- class="mode-select"
4182
- aria-label=${i18next.t('ai-assistant.label.conversation')}
4183
- ?disabled=${this.busy}
4184
- @change=${(event: Event) => this.onModePick((event.target as HTMLSelectElement).value)}>
4185
- ${this.modeChoices.map(
4186
- choice => html`<option value=${choice.kind} ?selected=${choice.kind === this.mode?.kind}>${choice.label}</option>`
4187
- )}
4188
- </select>
4189
- `}
4190
- `
4191
- }
4192
-
4193
4002
  private renderSessionTabs() {
4194
4003
  if (!this.sessions || this.sessions.length === 0) return nothing
4195
4004
  return html`
@@ -5072,6 +4881,7 @@ export class OxAssistantChat extends LitElement {
5072
4881
  const text = this.input.trim()
5073
4882
  const attachmentsToSend = [...this.attachments]
5074
4883
  if ((!text && attachmentsToSend.length === 0) || this.busy) return
4884
+ this.nextRequest = undefined
5075
4885
  this.attachments = []
5076
4886
  /* What was written wide is sent to be answered - the conversation comes back to show it. */
5077
4887
  this.composerWide = false
@@ -5257,6 +5067,8 @@ export class OxAssistantChat extends LitElement {
5257
5067
 
5258
5068
  /* 대화가 길어졌다는 서버 보고 — 닫지 않았다면 안내를 띄운다. */
5259
5069
  this.historyFolded = out.historyFolded ?? undefined
5070
+ /* The answer's next request - the input shows it and Tab writes it in (design/ai/screen-pattern.md §4-1). */
5071
+ this.nextRequest = typeof out.suggestion === 'string' && out.suggestion.trim() ? out.suggestion.trim() : undefined
5260
5072
 
5261
5073
  // 전송 성공 — picks 정리 (다음 메시지부터는 새 picks 만)
5262
5074
  this.pickedMentions.clear()
@@ -16,7 +16,7 @@ import { OxPrompt } from '@operato/popup'
16
16
  import { AppController, navigate, store } from '@operato/shell'
17
17
  import { ScrollbarStyles } from '@operato/styles'
18
18
 
19
- import { followPickedConversation, type AssistantMode } from './assistant-mode.js'
19
+ import type { AssistantMode } from './assistant-mode.js'
20
20
  import { AssistantSessionController, type AssistantSession, type AssistantSessionTarget } from '../utils/assistant-session-controller.js'
21
21
  import { assistantSessionTransport } from '../utils/assistant-session-transport.js'
22
22
  import { readAiCapability } from '../utils/assistant-capability-read.js'
@@ -59,20 +59,20 @@ setEntryProposalHandler(
59
59
  )
60
60
  )
61
61
 
62
- const MODES_QUERY = gql`
62
+ const MODE_QUERY = gql`
63
63
  query ($subject: String!) {
64
- assistantModes(subject: $subject) {
64
+ assistantModeFor(subject: $subject) {
65
65
  kind
66
66
  label
67
- isDefault
68
67
  }
69
68
  }
70
69
  `
71
70
 
72
- async function modesFor(subject: string): Promise<{ kind: string; label?: string; isDefault?: boolean }[]> {
73
- const response = await client.query({ query: MODES_QUERY, variables: { subject }, fetchPolicy: 'network-only' })
71
+ /** The one conversation for what the screen shows (design/ai/screen-pattern.md §3). */
72
+ async function modeFor(subject: string): Promise<{ kind: string; label?: string } | null> {
73
+ const response = await client.query({ query: MODE_QUERY, variables: { subject }, fetchPolicy: 'network-only' })
74
74
  if (response.errors?.length) throw new Error(response.errors[0].message)
75
- return response.data?.assistantModes ?? []
75
+ return response.data?.assistantModeFor ?? null
76
76
  }
77
77
 
78
78
  /**
@@ -305,8 +305,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
305
305
  private openedIdentity = ''
306
306
 
307
307
  private app = new AppController(this)
308
- /** The kind the person picked; kept while the surface stays the same. */
309
- private chosenKind = ''
310
308
  private revision = 0
311
309
  /** What the conversation was opened for - a send is refused when the screen moved on. */
312
310
  private openedFor = ''
@@ -418,7 +416,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
418
416
 
419
417
  private async requestSurface(surface: AssistantSurface | undefined) {
420
418
  if (!(await this.mayLeaveConversation())) return
421
- this.chosenKind = ''
422
419
  this.surface = surface
423
420
  await this.open()
424
421
  }
@@ -447,7 +444,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
447
444
  this.capability = 'unknown'
448
445
  if (this.openedIdentity) takeAssistantAsk()
449
446
  }
450
- if (next?.key !== this.surface?.key) this.chosenKind = ''
451
447
  this.surface = next
452
448
  void this.open()
453
449
  }
@@ -504,10 +500,9 @@ export class AssistantDock extends localize(i18next)(LitElement) {
504
500
  this.anchor = anchor
505
501
  if (!isAnchor(anchor)) return
506
502
 
507
- const modes = await modesFor(surface.subject)
503
+ const mode = await modeFor(surface.subject)
508
504
  if (ticket !== this.revision) return
509
- const chosen = modes.find(mode => mode.kind === this.chosenKind) ?? modes.find(mode => mode.isDefault) ?? modes[0]
510
- this.kind = chosen?.kind ?? ''
505
+ this.kind = mode?.kind ?? ''
511
506
  if (!this.kind) {
512
507
  this.anchor = { stuck: 'no-conversation', message: t('no-conversation', 'there is no conversation for this screen.') }
513
508
  return
@@ -551,28 +546,13 @@ export class AssistantDock extends localize(i18next)(LitElement) {
551
546
  if (revision !== this.controller.state.revision) this.conversationView++
552
547
  }
553
548
 
554
- private async selectSession(id: string, pickedKind?: string) {
549
+ private async selectSession(id: string) {
555
550
  if (id !== this.sessionId && !(await this.mayLeaveConversation())) return
556
551
  this.showingHistory = false
557
- const follow = followPickedConversation(pickedKind, this.kind)
558
- if (follow) {
559
- /* A conversation of another kind on this anchor - the dock follows it rather than refusing it. */
560
- this.chosenKind = follow
561
- void this.open().then(() => this.controller.select(id) && this.conversationView++)
562
- return
563
- }
564
552
  const previous = this.sessionId
565
553
  if (this.controller.select(id) && previous !== id) this.conversationView++
566
554
  }
567
555
 
568
- private onModeChange = async (event: CustomEvent) => {
569
- const kind = event.detail?.kind
570
- if (!kind || kind === this.kind) return
571
- if (!(await this.mayLeaveConversation())) return
572
- this.chosenKind = kind
573
- void this.open()
574
- }
575
-
576
556
  /* ── asks from other screens ────────────────────────────────────────────────────── */
577
557
 
578
558
  private async takeAsk() {
@@ -836,7 +816,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
836
816
  .anchorId=${(anchor as any).anchorId}
837
817
  .sessionId=${this.sessionId}
838
818
  .kind=${this.kind}
839
- @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId, event.detail.kind)}
819
+ @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId)}
840
820
  @assistant-session-reset=${() => this.newConversation()}
841
821
  @assistant-session-changed=${() => this.reloadSessions()}
842
822
  ></ox-assistant-session-manager>`
@@ -888,7 +868,6 @@ export class AssistantDock extends localize(i18next)(LitElement) {
888
868
  .autoAskRequestId=${this.asking?.requestId}
889
869
  .draft=${this.asking?.draft}
890
870
  .draftRequestId=${this.asking?.requestId}
891
- @assistant-mode-change=${this.onModeChange}
892
871
  @assistant-proposal-execute=${this.onProposal}
893
872
  @assistant-proposal-check=${this.onProposalCheck}
894
873
  @scene-edit-patch=${this.onScenePatch}
@@ -9,7 +9,7 @@
9
9
  * the slash list was a module global. A mode's wording now comes from the same declaration the
10
10
  * door reads, so the two cannot describe different conversations.
11
11
  */
12
- import { isDifferentConversation, resolveConversation, type AssistantMode, pickInitialMode, followPickedConversation } from './assistant-mode'
12
+ import { isDifferentConversation, resolveConversation, type AssistantMode } from './assistant-mode'
13
13
 
14
14
  const MODE: AssistantMode = { kind: 'twin.space', target: 'S-12', hostContext: { spaceId: 'S-12' } }
15
15
 
@@ -108,48 +108,6 @@ describe('isDifferentConversation', () => {
108
108
  * 되살리는 것은 고른 대화이지 세션이 아니다. 그리고 없어진 대화로 되살리지 않는다 — 그러면 대화가
109
109
  * 안 열리고 사람은 자기가 무엇을 잘못했는지 알 길이 없다.
110
110
  */
111
- describe('pickInitialMode', () => {
112
- const choices = [
113
- { kind: 'twin.board', label: '이 그림 고치기', isDefault: true },
114
- { kind: 'twin.space', label: '이 현장 지금', isDefault: false }
115
- ]
116
-
117
- it('★ 지난번에 고른 대화로 돌아간다 — 매번 같은 손질을 다시 하지 않는다', () => {
118
- expect(pickInitialMode('twin.board', choices, 'twin.space')).toBe('twin.space')
119
- })
120
-
121
- it('★ 지금 목록에 없는 기억은 따르지 않는다 — 안 열리는 대화로 들어간다', () => {
122
- expect(pickInitialMode('twin.board', choices, 'twin.gone')).toBe('twin.board')
123
- })
124
-
125
- it('★ 고를 것이 하나뿐이면 기억을 보지 않는다 — 고른 적이 없는 화면이다', () => {
126
- expect(pickInitialMode('plant.station', [choices[0]], 'twin.space')).toBe('plant.station')
127
- })
128
-
129
- it('기억이 없으면 host 가 준 것으로 선다', () => {
130
- expect(pickInitialMode('twin.board', choices, undefined)).toBe('twin.board')
131
- })
132
- })
133
-
134
- /*
135
- * ADR-0072 결정 3 — 목록에서 고른 대화로 화면이 따라간다.
136
- */
137
- describe('followPickedConversation', () => {
138
- it('★ 다른 대화의 세션을 고르면 그 대화로 간다 — 안 따라가면 문이 거절한다', () => {
139
- expect(followPickedConversation('figure.catalog', 'figure.modeller')).toBe('figure.catalog')
140
- })
141
-
142
- it('같은 대화면 그대로 둔다', () => {
143
- expect(followPickedConversation('figure.modeller', 'figure.modeller')).toBeUndefined()
144
- })
145
-
146
- it('★ 칸이 서기 전 세션은 지금 대화로 둔다 — 서버가 다음 턴에 찍는다', () => {
147
- expect(followPickedConversation(undefined, 'figure.modeller')).toBeUndefined()
148
- expect(followPickedConversation(null, 'figure.modeller')).toBeUndefined()
149
- expect(followPickedConversation('', 'figure.modeller')).toBeUndefined()
150
- })
151
- })
152
-
153
111
  describe('a board host keeps the board door', () => {
154
112
  test('★ with a mode, the door stays boardAIChat and the board defaults fill what the mode leaves out', () => {
155
113
  const c = resolveConversation(MODE, { ...HOST_PROPERTIES, chatEndpoint: 'boardAIChat' }, GLOBAL_DEFAULTS, undefined)
@@ -172,50 +172,3 @@ export function resolveConversation(
172
172
  stagedNotice: pick('stagedNotice')
173
173
  }
174
174
  }
175
-
176
- /** 한 subject 위에 설 수 있는 대화 하나 — 서버가 준 목록의 한 줄. */
177
- export interface AssistantModeChoice {
178
- kind: string
179
- label: string
180
- isDefault: boolean
181
- }
182
-
183
- /**
184
- * 이 화면을 열 때 어느 대화로 설 것인가 — ADR-0072 A17 ①.
185
- *
186
- * ── 왜 기억하나 ───────────────────────────────────────────────────────────
187
- * 보드를 열 때마다 저작 대화로 되돌아가면, 현장을 물으려고 온 사람은 화면을 열 때마다 같은 손질을
188
- * 다시 한다. 기억은 이 브라우저에만 남는다 — 서버에 칸을 만들지 않는다.
189
- *
190
- * ── 기억을 언제 버리나 ────────────────────────────────────────────────────
191
- * 기억한 종류가 지금 목록에 없으면 따르지 않는다. 대화가 하나 없어진 판으로 바뀌었는데 그 이름으로
192
- * 열려고 하면 대화가 안 열리고, 사람은 자기가 무엇을 잘못했는지 알 길이 없다. 그때는 host 가 준
193
- * 대화로 둔다.
194
- *
195
- * 고를 것이 하나뿐이면 기억을 보지 않는다 — 고른 적이 없는 화면이다.
196
- */
197
- export function pickInitialMode(
198
- current: string | undefined,
199
- choices: ReadonlyArray<AssistantModeChoice>,
200
- remembered: string | undefined
201
- ): string | undefined {
202
- if (choices.length < 2) return current
203
- if (!remembered || remembered === current) return current
204
- return choices.some(choice => choice.kind === remembered) ? remembered : current
205
- }
206
-
207
- /**
208
- * 목록에서 고른 대화로 따라갈 것인가 — ADR-0072 결정 3.
209
- *
210
- * 목록은 한 대상의 대화를 **모두** 보여 주고, 그 대상 위에는 대화가 둘 설 수 있다(A17). 고른 것이 지금
211
- * 열린 대화와 다른데 화면이 그대로 있으면 문이 거절하고(`session-kind-mismatch`), 목록은 눌리는데 안
212
- * 열리는 목록이 된다 — 선언은 있고 길이 없는 그 모양이다.
213
- *
214
- * 칸이 서기 전에 난 세션에는 값이 없다. 그때는 지금 대화로 둔다 — 서버가 다음 턴에 지금 대화로 찍는다.
215
- *
216
- * `undefined` 는 「그대로 둔다」는 뜻이다.
217
- */
218
- export function followPickedConversation(pickedKind: string | undefined | null, currentKind: string | undefined): string | undefined {
219
- if (!pickedKind || pickedKind === currentKind) return undefined
220
- return pickedKind
221
- }
@@ -62,7 +62,7 @@ export interface BoardSurface {
62
62
  export interface AssistantSurface {
63
63
  /** Identity - a different key is a different conversation. */
64
64
  key: string
65
- /** Matched against the server's mode `subjects` (assistantModes(subject)). */
65
+ /** Which conversation opens here: the one mode whose `subjects` name it (assistantModeFor(subject)). */
66
66
  subject: string
67
67
  /** The session anchor, or why none could stand. May ask the server (a row uuid, a domain id). */
68
68
  anchor(): Promise<AnchorResult> | AnchorResult
@@ -60,16 +60,18 @@ export function appendWords(written: string, words: string): string {
60
60
  }
61
61
 
62
62
  /**
63
- * The request the empty box suggests, which a Tab writes in: the conversation's first example request, its own
64
- * before the host's. A placeholder is how to write, not a request, so it is never the suggestion. Once the
65
- * conversation is under way an opening example no longer fits, and nothing is suggested.
63
+ * The request the empty box suggests, which a Tab writes in (design/ai/screen-pattern.md §4-1). Before anything is
64
+ * said it is the conversation's first example request, its own before the host's; after an answer it is the next
65
+ * request that answer named. A placeholder is how to write, not a request, so it is never the suggestion.
66
66
  */
67
67
  export function composerSuggestion(input: {
68
68
  started: boolean
69
69
  modeExamples?: Array<{ items: string[] }>
70
70
  hostExamples?: Array<{ items: string[] }>
71
+ /** The next request the last answer named. */
72
+ answered?: string
71
73
  }): string | undefined {
72
- if (input.started) return undefined
74
+ if (input.started) return input.answered?.trim() || undefined
73
75
  const groups = input.modeExamples?.length ? input.modeExamples : input.hostExamples
74
76
  return groups?.find(group => group.items.length > 0)?.items[0]
75
77
  }