@things-factory/board-ui 10.1.47 → 10.1.52

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.
@@ -23,26 +23,11 @@ export declare class BoardModellerPage extends PageView {
23
23
  componentGroupList?: any[];
24
24
  propertyEditor: any;
25
25
  preparing?: boolean;
26
- /** AI 협력 세션 id — 현재 활성 세션. boardId 변경 시 자동 ensure. 다중 세션 시 활성 1개. */
27
- chatSessionId?: string;
28
- /** 보드의 모든 ChatSession (탭으로 표시). */
29
- chatSessions: Array<{
30
- id: string;
31
- name?: string;
32
- createdAt?: string;
33
- }>;
34
- /** AI 채팅 패널 열림 상태 (기본 닫힘) */
35
- aiPanelOpen: boolean;
36
26
  /**
37
- * The conversation the AI panel stands in — the mode the app server registered as the default for
38
- * a board (ADR-0061 decision 7). The server then owns persona, tool categories and model. Absent
39
- * when the app registered none: the panel keeps its older, undeclared behaviour.
27
+ * The AI conversation open for this board - the framework's dock owns it (design/ai/shared-dock.md) and tells
28
+ * this page through the board surface's `sessionChanged`. Direct edits are recorded under it (C3).
40
29
  */
41
- aiMode?: {
42
- kind: string;
43
- target?: string;
44
- };
45
- private aiModeResolved;
30
+ private chatSessionId?;
46
31
  /**
47
32
  * 적용된 patch 별 inverse op 시퀀스 — Revert 기능용.
48
33
  *
@@ -84,23 +69,6 @@ export declare class BoardModellerPage extends PageView {
84
69
  description: string;
85
70
  };
86
71
  refresh(): Promise<void>;
87
- /** 패널 토글 시 호출 — 열 때만 chatSession 보장 (세션 리스트 로드 + 활성 세션 결정) */
88
- private toggleAIPanel;
89
- /** Ask the server which conversation a board opens in. Once per page; the target follows the board. */
90
- private resolveAIMode;
91
- private onAssistantModeChange;
92
- /** 보드의 모든 ChatSession 목록을 로드 + 활성 세션 보장.
93
- * - 세션이 0 개면 startBoardAISession 으로 첫 세션 생성 (단일 세션 UX 호환)
94
- * - 활성 세션 미설정 시 첫 번째 세션을 활성으로
95
- * - 이미 활성 세션 set 돼있고 list 에 포함되면 유지 */
96
- private ensureChatSession;
97
- /** `@` mention popup 의 도메인 사용자 후보 — chat 의 userProvider prop 으로 wire.
98
- * query 변할 때마다 호출되므로 server query (`domainUsersForMention`) 를 그대로 위임. */
99
- private fetchDomainUsersForMention;
100
- /** 채팅 패널의 탭 클릭 — 활성 세션 전환. */
101
- private onChatSessionSwitch;
102
- /** 채팅 패널의 "+" 버튼 — 새 세션 생성 후 활성으로 전환. */
103
- private onChatSessionCreate;
104
72
  /**
105
73
  * 모델러의 selected (things-scene Component 인스턴스 배열) 에서 refid 추출.
106
74
  *
@@ -194,6 +162,13 @@ export declare class BoardModellerPage extends PageView {
194
162
  private findTemplateByType;
195
163
  updated(changes: any): Promise<void>;
196
164
  disconnectedCallback(): void;
165
+ /**
166
+ * This board, told to the framework's AI dock (design/ai/shared-dock.md §3). The dock owns the
167
+ * conversation - sessions, modes, the manager - and hands the chat's scene edits back to this page.
168
+ * Decided 2026-09-25: the modeller no longer carries its own "AI" button and panel; the band's forum
169
+ * button opens the same dock as every other screen.
170
+ */
171
+ private provideAssistantBoard;
197
172
  pageUpdated(changes: any, lifecycle: any): void;
198
173
  render(): import("lit-html").TemplateResult<1>;
199
174
  updateBoard(): Promise<void>;
@@ -15,6 +15,7 @@ import { PageView, FontController } from '@operato/shell';
15
15
  import { hasPrivilege } from '@things-factory/auth-base/dist-client/index.js';
16
16
  import { applyScenePatchVerbose, dispatchSceneAction, dispatchSceneEditOp, findSceneComponent } from '@operato/scene-ops';
17
17
  import { client, gqlContext, subscribe } from '@operato/graphql';
18
+ import { clearAssistantSurface, provideAssistantSurface } from '@things-factory/ai-assistant/dist-client/index.js';
18
19
  import { i18next } from '@operato/i18n';
19
20
  import { OxPrompt } from '@operato/popup/ox-prompt.js';
20
21
  import { provider } from '@operato/board/board-provider.js';
@@ -77,11 +78,6 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
77
78
  this.overlay = null;
78
79
  this.scene = null;
79
80
  this.componentGroupList = BoardModeller.groups;
80
- /** 보드의 모든 ChatSession (탭으로 표시). */
81
- this.chatSessions = [];
82
- /** AI 채팅 패널 열림 상태 (기본 닫힘) */
83
- this.aiPanelOpen = false;
84
- this.aiModeResolved = false;
85
81
  /**
86
82
  * 적용된 patch 별 inverse op 시퀀스 — Revert 기능용.
87
83
  *
@@ -96,67 +92,6 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
96
92
  this._syncedModel = null;
97
93
  this.board = null;
98
94
  this._fontCtrl = new FontController(this);
99
- /** `@` mention popup 의 도메인 사용자 후보 — chat 의 userProvider prop 으로 wire.
100
- * query 변할 때마다 호출되므로 server query (`domainUsersForMention`) 를 그대로 위임. */
101
- this.fetchDomainUsersForMention = async (query) => {
102
- try {
103
- const result = await client.query({
104
- query: gql `
105
- query DomainUsersForMention($query: String, $limit: Int) {
106
- domainUsersForMention(query: $query, limit: $limit) {
107
- id
108
- name
109
- email
110
- }
111
- }
112
- `,
113
- variables: { query: query || null, limit: 50 },
114
- context: gqlContext(),
115
- fetchPolicy: 'cache-first'
116
- });
117
- return result.data?.domainUsersForMention ?? [];
118
- }
119
- catch (ex) {
120
- // 조용히 — chat panel 이 빈 결과로 처리
121
- console.warn('[board-modeller-page] fetchDomainUsersForMention failed:', ex);
122
- return [];
123
- }
124
- };
125
- /** 채팅 패널의 탭 클릭 — 활성 세션 전환. */
126
- this.onChatSessionSwitch = (e) => {
127
- const newId = e.detail?.sessionId;
128
- if (typeof newId === 'string' && newId !== this.chatSessionId) {
129
- this.chatSessionId = newId;
130
- }
131
- };
132
- /** 채팅 패널의 "+" 버튼 — 새 세션 생성 후 활성으로 전환. */
133
- this.onChatSessionCreate = async () => {
134
- if (!this.boardId)
135
- return;
136
- try {
137
- const created = await client.mutate({
138
- mutation: gql `
139
- mutation CreateChatSession($boardId: String!, $name: String) {
140
- createChatSession(boardId: $boardId, name: $name) {
141
- id
142
- name
143
- createdAt
144
- }
145
- }
146
- `,
147
- variables: { boardId: this.boardId, name: null },
148
- context: gqlContext()
149
- });
150
- const s = created.data?.createChatSession;
151
- if (s) {
152
- this.chatSessions = [...this.chatSessions, s];
153
- this.chatSessionId = s.id;
154
- }
155
- }
156
- catch (ex) {
157
- notifyError(ex);
158
- }
159
- };
160
95
  /**
161
96
  * AI 채팅 mention popup (`#`) 검색 위임 — things-scene SceneSearchEngine 으로 wrap.
162
97
  *
@@ -361,6 +296,8 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
361
296
 
362
297
  overflow: hidden;
363
298
  position: relative;
299
+ /* The modeller's own layers (the save button is z-index 1000) stay inside the page - the AI dock opens over it. */
300
+ isolation: isolate;
364
301
  }
365
302
 
366
303
  .modeller-area {
@@ -375,54 +312,6 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
375
312
  flex: 1;
376
313
  }
377
314
 
378
- /*
379
- * The assistant's door and its column, on the scheme's own surfaces. The door is a quiet
380
- * tonal pill on the canvas, like the save FAB beside it; open, it takes the pressed tone.
381
- */
382
- .ai-toggle {
383
- position: absolute;
384
- right: 12px;
385
- bottom: 12px;
386
- z-index: 10;
387
- display: inline-flex;
388
- align-items: center;
389
- gap: 6px;
390
- height: 36px;
391
- padding: 0 14px 0 11px;
392
- border: 1px solid var(--md-sys-color-outline-variant);
393
- border-radius: 999px;
394
- background: var(--md-sys-color-surface-container-high);
395
- color: var(--md-sys-color-on-surface);
396
- font: 500 0.8rem/1 var(--theme-font, Roboto, sans-serif);
397
- cursor: pointer;
398
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
399
- transition: background 0.15s, transform 0.1s;
400
- }
401
- .ai-toggle md-icon {
402
- --md-icon-size: 18px;
403
- color: var(--md-sys-color-primary);
404
- font-variation-settings: 'FILL' 1;
405
- }
406
- .ai-toggle:hover { background: var(--md-sys-color-surface-container-highest); }
407
- .ai-toggle:active { transform: scale(0.97); }
408
- .ai-toggle.open {
409
- border-color: transparent;
410
- background: var(--md-sys-color-secondary-container);
411
- color: var(--md-sys-color-on-secondary-container);
412
- }
413
- .ai-toggle.open md-icon { color: inherit; font-variation-settings: 'FILL' 0; }
414
-
415
- .ai-panel {
416
- flex: 0 0 340px;
417
- display: flex;
418
- min-width: 0;
419
- border-left: 1px solid var(--md-sys-color-outline-variant);
420
- background: var(--md-sys-color-surface-container-low);
421
- color: var(--md-sys-color-on-surface);
422
- }
423
-
424
- ox-assistant-chat { flex: 1; }
425
-
426
315
  ox-oops-note {
427
316
  display: block;
428
317
  position: absolute;
@@ -431,14 +320,6 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
431
320
  transform: translate(-50%, -50%);
432
321
  }
433
322
 
434
- @media (max-width: 900px) {
435
- .ai-panel {
436
- flex-basis: 100%;
437
- position: absolute;
438
- inset: 0;
439
- z-index: 20;
440
- }
441
- }
442
323
  `
443
324
  ]; }
444
325
  get knownTypes() {
@@ -599,16 +480,9 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
599
480
  this.model = {
600
481
  ...this.board.model
601
482
  };
602
- // AI 협력 세션 — 보드 전환 시 이전 보드의 세션 / 세션 리스트 정리 (chat panel 의
603
- // sessionId/sessions 변화 감지 → history 비우고 새로 로드). 패널 열린 채 보드 옮겼다면
604
- // 새 보드의 세션 리스트를 즉시 ensure (fire-and-forget — 에러는 ensureChatSession 처리).
483
+ /* Another board is another conversation - the dock reopens for the new surface. */
605
484
  this.chatSessionId = undefined;
606
- this.chatSessions = [];
607
- if (this.aiMode)
608
- this.aiMode = { ...this.aiMode, target: this.boardId ?? undefined };
609
- if (this.aiPanelOpen && this.boardId) {
610
- this.ensureChatSession();
611
- }
485
+ this.provideAssistantBoard();
612
486
  }
613
487
  catch (ex) {
614
488
  notifyError(ex);
@@ -618,98 +492,6 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
618
492
  this.updateContext();
619
493
  }
620
494
  }
621
- /** 패널 토글 시 호출 — 열 때만 chatSession 보장 (세션 리스트 로드 + 활성 세션 결정) */
622
- async toggleAIPanel() {
623
- const willOpen = !this.aiPanelOpen;
624
- if (willOpen && this.boardId) {
625
- await Promise.all([this.ensureChatSession(), this.resolveAIMode()]);
626
- }
627
- this.aiPanelOpen = willOpen;
628
- }
629
- /** Ask the server which conversation a board opens in. Once per page; the target follows the board. */
630
- async resolveAIMode() {
631
- if (this.aiModeResolved)
632
- return;
633
- this.aiModeResolved = true;
634
- try {
635
- const result = await client.query({
636
- query: gql `
637
- query BoardAssistantModes($subject: String!) {
638
- assistantModes(subject: $subject) {
639
- kind
640
- isDefault
641
- }
642
- }
643
- `,
644
- variables: { subject: 'board' },
645
- context: gqlContext()
646
- });
647
- const modes = (result.data?.assistantModes ?? []);
648
- const chosen = modes.find(m => m.isDefault) ?? modes[0];
649
- this.aiMode = chosen ? { kind: chosen.kind, target: this.boardId ?? undefined } : undefined;
650
- }
651
- catch (e) {
652
- /* A server without ai-assistant's mode door: stay undeclared rather than break the panel. */
653
- this.aiMode = undefined;
654
- }
655
- }
656
- onAssistantModeChange(e) {
657
- const kind = e.detail?.kind;
658
- if (kind)
659
- this.aiMode = { kind, target: this.boardId ?? undefined };
660
- }
661
- /** 보드의 모든 ChatSession 목록을 로드 + 활성 세션 보장.
662
- * - 세션이 0 개면 startBoardAISession 으로 첫 세션 생성 (단일 세션 UX 호환)
663
- * - 활성 세션 미설정 시 첫 번째 세션을 활성으로
664
- * - 이미 활성 세션 set 돼있고 list 에 포함되면 유지 */
665
- async ensureChatSession() {
666
- if (!this.boardId)
667
- return;
668
- try {
669
- const result = await client.query({
670
- query: gql `
671
- query ChatSessionsByBoard($boardId: String!) {
672
- chatSessionsByBoard(boardId: $boardId) {
673
- id
674
- name
675
- createdAt
676
- }
677
- }
678
- `,
679
- variables: { boardId: this.boardId },
680
- context: gqlContext(),
681
- fetchPolicy: 'network-only'
682
- });
683
- let sessions = result.data?.chatSessionsByBoard ?? [];
684
- if (sessions.length === 0) {
685
- // 첫 세션 ensure (idempotent)
686
- const created = await client.mutate({
687
- mutation: gql `
688
- mutation StartBoardAISession($boardId: String!) {
689
- startBoardAISession(boardId: $boardId) {
690
- id
691
- name
692
- createdAt
693
- }
694
- }
695
- `,
696
- variables: { boardId: this.boardId },
697
- context: gqlContext()
698
- });
699
- const s = created.data?.startBoardAISession;
700
- if (s)
701
- sessions = [s];
702
- }
703
- this.chatSessions = sessions;
704
- // 활성 세션 결정 — 기존 active 가 list 에 있으면 유지, 아니면 첫 번째
705
- if (!this.chatSessionId || !sessions.some(s => s.id === this.chatSessionId)) {
706
- this.chatSessionId = sessions[0]?.id;
707
- }
708
- }
709
- catch (ex) {
710
- notifyError(ex);
711
- }
712
- }
713
495
  /**
714
496
  * 모델러의 selected (things-scene Component 인스턴스 배열) 에서 refid 추출.
715
497
  *
@@ -898,7 +680,8 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
898
680
  async updated(changes) {
899
681
  if (changes.has('boardId')) {
900
682
  try {
901
- if (await hasPrivilege({ privilege: 'mutation', category: 'board' })) {
683
+ /* The same rule as the board's own save door (board-service): a domain owner edits without a role. */
684
+ if (await hasPrivilege({ privilege: 'mutation', category: 'board', domainOwnerGranted: true })) {
902
685
  this.refresh();
903
686
  // C2: 보드 변경 시 그 보드의 실시간 patch 구독 (이전 보드 구독은 해제).
904
687
  if (this.boardId)
@@ -924,6 +707,44 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
924
707
  disconnectedCallback() {
925
708
  super.disconnectedCallback();
926
709
  this._unsubscribeBoardPatches();
710
+ clearAssistantSurface(this);
711
+ }
712
+ /**
713
+ * This board, told to the framework's AI dock (design/ai/shared-dock.md §3). The dock owns the
714
+ * conversation - sessions, modes, the manager - and hands the chat's scene edits back to this page.
715
+ * Decided 2026-09-25: the modeller no longer carries its own "AI" button and panel; the band's forum
716
+ * button opens the same dock as every other screen.
717
+ */
718
+ provideAssistantBoard() {
719
+ if (!this.boardId)
720
+ return;
721
+ const boardId = this.boardId;
722
+ provideAssistantSurface(this, {
723
+ key: `board:${boardId}`,
724
+ subject: 'board',
725
+ icon: 'dashboard',
726
+ label: () => this.boardName || i18next.t('text.ai_assistant'),
727
+ detail: () => i18next.t('label.board', { defaultValue: 'board' }),
728
+ anchor: () => ({ anchorType: 'board', anchorId: boardId }),
729
+ sessionChanged: sessionId => (this.chatSessionId = sessionId || undefined),
730
+ board: {
731
+ chatEndpoint: 'boardAIChat',
732
+ currentBoard: () => this.model,
733
+ boardProvider: () => this.getLiveBoard(),
734
+ selectedRefids: () => this.extractSelectedRefids(),
735
+ knownTypes: () => this.knownTypes,
736
+ categories: () => this.knownCategories,
737
+ componentSchemas: () => this.componentSchemas,
738
+ searchProvider: this.searchSceneForMention,
739
+ applyPatch: (detail) => {
740
+ /* The modeller tells the person which ops missed itself, so the dock is told none to repeat. */
741
+ this.onSceneEditPatch(new CustomEvent('scene-edit-patch', { detail }));
742
+ return { applied: 0, missed: 0 };
743
+ },
744
+ revertPatch: (patchId) => this.onBoardEditRevert(new CustomEvent('board-edit-revert', { detail: { patchId } })),
745
+ applyAction: (detail) => this.onSceneActionExecute(new CustomEvent('scene-action-execute', { detail }))
746
+ }
747
+ });
927
748
  }
928
749
  pageUpdated(changes, lifecycle) {
929
750
  if (this.active) {
@@ -933,6 +754,7 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
933
754
  this.boardId = lifecycle.resourceId;
934
755
  }
935
756
  else {
757
+ clearAssistantSurface(this);
936
758
  this.boardId = null;
937
759
  this.board = null;
938
760
  this.model = null;
@@ -981,44 +803,7 @@ let BoardModellerPage = class BoardModellerPage extends PageView {
981
803
  ${this.scene && boardPlacementProvider()
982
804
  ? html `<board-placement-tray .boardId=${this.boardId} .scene=${this.scene}></board-placement-tray>`
983
805
  : nothing}
984
- ${this.boardId
985
- ? html `
986
- <button
987
- class="ai-toggle ${this.aiPanelOpen ? 'open' : ''}"
988
- title="${i18next.t('text.ai_assistant') || 'AI Assistant'}"
989
- @click=${this.toggleAIPanel}>
990
- <md-icon>${this.aiPanelOpen ? 'close' : 'auto_awesome'}</md-icon>AI
991
- </button>
992
- `
993
- : nothing}
994
806
  </div>
995
- ${this.aiPanelOpen
996
- ? html `
997
- <div class="ai-panel">
998
- <ox-assistant-chat
999
- chat-endpoint="boardAIChat"
1000
- subject="board"
1001
- .mode=${this.aiMode}
1002
- @assistant-mode-change=${this.onAssistantModeChange}
1003
- .sessionId=${this.chatSessionId}
1004
- .sessions=${this.chatSessions}
1005
- .currentBoard=${this.model}
1006
- .boardProvider=${() => this.getLiveBoard()}
1007
- .searchProvider=${this.searchSceneForMention}
1008
- .userProvider=${this.fetchDomainUsersForMention}
1009
- .knownTypes=${this.knownTypes}
1010
- .categories=${this.knownCategories}
1011
- .componentSchemas=${this.componentSchemas}
1012
- .selectedRefids=${this.extractSelectedRefids()}
1013
- @session-switch=${this.onChatSessionSwitch}
1014
- @session-create=${this.onChatSessionCreate}
1015
- @scene-edit-patch=${this.onSceneEditPatch}
1016
- @board-edit-revert=${this.onBoardEditRevert}
1017
- @scene-action-execute=${this.onSceneActionExecute}>
1018
- </ox-assistant-chat>
1019
- </div>
1020
- `
1021
- : nothing}
1022
807
  `;
1023
808
  }
1024
809
  async updateBoard() {
@@ -1156,22 +941,6 @@ __decorate([
1156
941
  property({ type: Boolean }),
1157
942
  __metadata("design:type", Boolean)
1158
943
  ], BoardModellerPage.prototype, "preparing", void 0);
1159
- __decorate([
1160
- state(),
1161
- __metadata("design:type", String)
1162
- ], BoardModellerPage.prototype, "chatSessionId", void 0);
1163
- __decorate([
1164
- state(),
1165
- __metadata("design:type", Array)
1166
- ], BoardModellerPage.prototype, "chatSessions", void 0);
1167
- __decorate([
1168
- state(),
1169
- __metadata("design:type", Object)
1170
- ], BoardModellerPage.prototype, "aiPanelOpen", void 0);
1171
- __decorate([
1172
- state(),
1173
- __metadata("design:type", Object)
1174
- ], BoardModellerPage.prototype, "aiMode", void 0);
1175
944
  __decorate([
1176
945
  query('ox-board-modeller'),
1177
946
  __metadata("design:type", BoardModeller)