Skip to content

Commit 8cfc751

Browse files
committed
fix(chat): fix chart retary and request
1 parent 44d3cc8 commit 8cfc751

2 files changed

Lines changed: 252 additions & 11 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { BehaviorSubject, Subject } from 'rxjs';
2+
import { ElementChatComponent } from './chat.component';
3+
4+
describe('ElementChatComponent', () => {
5+
let component: ElementChatComponent;
6+
let viewService: {
7+
currentView$: BehaviorSubject<any>;
8+
};
9+
let settingService: {
10+
globalSetting: any;
11+
globalSetting$: BehaviorSubject<any>;
12+
};
13+
let drawerStateService: {
14+
sendComponentMessage: jasmine.Spy;
15+
};
16+
let iframeCommunicationService: {
17+
message$: Subject<any>;
18+
};
19+
let chatWindow: {
20+
postMessage: jasmine.Spy;
21+
};
22+
let terminalWindow: {
23+
postMessage: jasmine.Spy;
24+
};
25+
26+
const dispatchChatMessage = (event: Partial<MessageEvent>) => {
27+
(component as any).onChatWindowMessage(event as MessageEvent);
28+
};
29+
30+
const readyMessage = (overrides: Partial<MessageEvent> = {}) => ({
31+
source: chatWindow as unknown as Window,
32+
origin: window.location.origin,
33+
data: { name: 'CHAT_IFRAME_READY' },
34+
...overrides
35+
});
36+
37+
beforeEach(() => {
38+
viewService = {
39+
currentView$: new BehaviorSubject<any>(null)
40+
};
41+
settingService = {
42+
globalSetting: {
43+
CHAT_AI_ENABLED: true,
44+
CHAT_AI_METHOD: 'api'
45+
},
46+
globalSetting$: new BehaviorSubject<any>({
47+
CHAT_AI_ENABLED: true,
48+
CHAT_AI_METHOD: 'api'
49+
})
50+
};
51+
drawerStateService = {
52+
sendComponentMessage: jasmine.createSpy('sendComponentMessage')
53+
};
54+
iframeCommunicationService = {
55+
message$: new Subject<any>()
56+
};
57+
chatWindow = {
58+
postMessage: jasmine.createSpy('chatWindow.postMessage')
59+
};
60+
terminalWindow = {
61+
postMessage: jasmine.createSpy('terminalWindow.postMessage')
62+
};
63+
64+
component = new ElementChatComponent(
65+
viewService as any,
66+
settingService as any,
67+
drawerStateService as any,
68+
iframeCommunicationService as any
69+
);
70+
component.iframeRef = {
71+
nativeElement: {
72+
contentWindow: chatWindow
73+
}
74+
} as any;
75+
76+
component.ngOnInit();
77+
viewService.currentView$.next({
78+
id: 'view-1',
79+
name: 'Terminal 1',
80+
iframeElement: terminalWindow,
81+
terminalContentData: {
82+
content: 'ls -la',
83+
command: 'ls -la'
84+
}
85+
});
86+
});
87+
88+
afterEach(() => {
89+
component.ngOnDestroy();
90+
});
91+
92+
it('does not post open before the iframe is ready', () => {
93+
component.showChatAI();
94+
95+
expect(component.chatAIShown).toBeTrue();
96+
expect(chatWindow.postMessage).not.toHaveBeenCalled();
97+
});
98+
99+
it('replays the current open state exactly once after a valid ready message', () => {
100+
component.showChatAI();
101+
102+
dispatchChatMessage(readyMessage());
103+
dispatchChatMessage(readyMessage());
104+
105+
expect(component.chatIframeReady).toBeTrue();
106+
expect(chatWindow.postMessage).toHaveBeenCalledTimes(2);
107+
expect(chatWindow.postMessage.calls.argsFor(0)).toEqual([
108+
{
109+
name: 'current_terminal_content',
110+
data: {
111+
viewId: 'view-1',
112+
viewName: 'Terminal 1',
113+
content: 'ls -la',
114+
command: 'ls -la'
115+
}
116+
},
117+
window.location.origin
118+
]);
119+
expect(chatWindow.postMessage.calls.argsFor(1)).toEqual([
120+
{
121+
name: 'CHAT_PANEL_COMMAND',
122+
data: { action: 'open' }
123+
},
124+
window.location.origin
125+
]);
126+
});
127+
128+
it('replays only the final close state when the panel was opened then closed before ready', () => {
129+
component.showChatAI();
130+
(component as any).closeChatAI();
131+
132+
dispatchChatMessage(readyMessage());
133+
134+
expect(chatWindow.postMessage).toHaveBeenCalledTimes(1);
135+
expect(chatWindow.postMessage).toHaveBeenCalledWith(
136+
{
137+
name: 'CHAT_PANEL_COMMAND',
138+
data: { action: 'close' }
139+
},
140+
window.location.origin
141+
);
142+
});
143+
144+
it('ignores ready messages from the wrong source or origin', () => {
145+
component.showChatAI();
146+
147+
dispatchChatMessage(
148+
readyMessage({
149+
source: {} as Window
150+
})
151+
);
152+
dispatchChatMessage(
153+
readyMessage({
154+
origin: 'https://example.invalid'
155+
})
156+
);
157+
158+
expect(component.chatIframeReady).toBeFalse();
159+
expect(chatWindow.postMessage).not.toHaveBeenCalled();
160+
});
161+
162+
it('posts open immediately once the iframe is already ready', () => {
163+
dispatchChatMessage(readyMessage());
164+
chatWindow.postMessage.calls.reset();
165+
166+
component.showChatAI();
167+
168+
expect(chatWindow.postMessage.calls.argsFor(0)).toEqual([
169+
{
170+
name: 'current_terminal_content',
171+
data: {
172+
viewId: 'view-1',
173+
viewName: 'Terminal 1',
174+
content: 'ls -la',
175+
command: 'ls -la'
176+
}
177+
},
178+
window.location.origin
179+
]);
180+
expect(chatWindow.postMessage.calls.argsFor(1)).toEqual([
181+
{
182+
name: 'CHAT_PANEL_COMMAND',
183+
data: { action: 'open' }
184+
},
185+
window.location.origin
186+
]);
187+
});
188+
189+
it('resets iframe readiness when chat ai is disabled and enabled again', () => {
190+
dispatchChatMessage(readyMessage());
191+
chatWindow.postMessage.calls.reset();
192+
193+
settingService.globalSetting = {
194+
CHAT_AI_ENABLED: false,
195+
CHAT_AI_METHOD: 'api'
196+
};
197+
settingService.globalSetting$.next(settingService.globalSetting);
198+
199+
expect(component.chatIframeReady).toBeFalse();
200+
expect(component.iframeURL).toBe('');
201+
202+
settingService.globalSetting = {
203+
CHAT_AI_ENABLED: true,
204+
CHAT_AI_METHOD: 'api'
205+
};
206+
settingService.globalSetting$.next(settingService.globalSetting);
207+
208+
component.showChatAI();
209+
210+
expect(chatWindow.postMessage).not.toHaveBeenCalled();
211+
});
212+
});

src/app/elements/chat/chat.component.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class ElementChatComponent implements OnInit, OnDestroy {
3434
chatAIShown = false;
3535
chatPanelExpanded = false;
3636
isDragging = false;
37+
chatIframeReady = false;
3738

3839
private readonly subscriptions = new Subscription();
3940
private readonly dragThreshold = 3;
@@ -105,11 +106,15 @@ export class ElementChatComponent implements OnInit, OnDestroy {
105106
this.subscriptions.add(
106107
this._settingSvc.globalSetting$.subscribe(setting => {
107108
if (!setting.CHAT_AI_ENABLED) {
109+
this.chatIframeReady = false;
110+
this.iframeURL = '';
108111
this.closeChatAI(false);
109112
return;
110113
}
111114

112115
if (setting.CHAT_AI_METHOD === 'embed') {
116+
this.chatIframeReady = false;
117+
this.iframeURL = '';
113118
this.closeChatAI(false);
114119
this.insertEmbedScript();
115120
} else if (setting.CHAT_AI_METHOD === 'api') {
@@ -139,16 +144,10 @@ export class ElementChatComponent implements OnInit, OnDestroy {
139144
}
140145

141146
showChatAI(): void {
142-
const chatWindow = this.iframeRef?.nativeElement.contentWindow;
143-
if (!chatWindow) {
144-
return;
145-
}
146-
147147
this.currentView?.iframeElement?.postMessage({ name: 'CLOSE' }, '*');
148-
this.postCurrentTerminalContextToChatAI();
149-
this.postChatCommand('open');
150148
this.chatPanelExpanded = false;
151149
this.chatAIShown = true;
150+
this.syncChatPanelStateToIframe();
152151
}
153152

154153
handleShowDrawer(): void {
@@ -292,7 +291,12 @@ export class ElementChatComponent implements OnInit, OnDestroy {
292291
}
293292

294293
private listenChatAI(): void {
295-
this.iframeURL = withUIBase('#/chat/chat-ai?from=luna');
294+
const iframeURL = withUIBase('#/chat/chat-ai?from=luna');
295+
if (this.iframeURL !== iframeURL) {
296+
this.chatIframeReady = false;
297+
this.iframeURL = iframeURL;
298+
}
299+
296300
if (!this.chatMessageListenerRegistered) {
297301
window.addEventListener('message', this.onChatWindowMessage);
298302
this.chatMessageListenerRegistered = true;
@@ -320,6 +324,16 @@ export class ElementChatComponent implements OnInit, OnDestroy {
320324
return;
321325
}
322326

327+
if (message.name === 'CHAT_IFRAME_READY') {
328+
if (this.chatIframeReady) {
329+
return;
330+
}
331+
332+
this.chatIframeReady = true;
333+
this.syncChatPanelStateToIframe();
334+
return;
335+
}
336+
323337
if (message.name === 'CHAT_PANEL_STATE') {
324338
this.chatAIShown = Boolean(message.data?.open);
325339
this.chatPanelExpanded = message.data?.mode === 'expanded';
@@ -335,11 +349,12 @@ export class ElementChatComponent implements OnInit, OnDestroy {
335349
};
336350

337351
private closeChatAI(notifyChat = true): void {
338-
if (notifyChat) {
339-
this.postChatCommand('close');
340-
}
341352
this.chatAIShown = false;
342353
this.chatPanelExpanded = false;
354+
355+
if (notifyChat) {
356+
this.syncChatPanelStateToIframe();
357+
}
343358
}
344359

345360
private postChatCommand(action: 'open' | 'close'): void {
@@ -363,6 +378,20 @@ export class ElementChatComponent implements OnInit, OnDestroy {
363378
}
364379
}
365380

381+
private syncChatPanelStateToIframe(): void {
382+
if (!this.chatIframeReady) {
383+
return;
384+
}
385+
386+
if (this.chatAIShown) {
387+
this.postCurrentTerminalContextToChatAI();
388+
this.postChatCommand('open');
389+
return;
390+
}
391+
392+
this.postChatCommand('close');
393+
}
394+
366395
private clampLauncherPosition(position: LauncherPosition): LauncherPosition {
367396
const rect = this.launcherRef?.nativeElement.getBoundingClientRect();
368397
const width = rect?.width ?? 40;

0 commit comments

Comments
 (0)