Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/cloud/src/app/@shared/chat/preview/preview.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class ChatConversationPreviewComponent {
readonly #audioRecorder = inject(AudioRecorderService)
readonly #synthesizeService = inject(SynthesizeService)
readonly #destroyRef = inject(DestroyRef)

#destroyed = false

// Inputs
Expand Down Expand Up @@ -250,8 +251,13 @@ export class ChatConversationPreviewComponent {
effect(() => this.#audioRecorder.xpert.set(this.xpert() as IXpert), { allowSignalWrites: true })
effect(() => this.input.set(this.#audioRecorder.text()), { allowSignalWrites: true })

// Set flag when component is destroyed to prevent operations on destroyed component
// Also clean up chatSubscription as an extra safety measure
this.#destroyRef.onDestroy(() => {
this.#destroyed = true

Copilot AI Jan 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DestroyRef.onDestroy() callback only sets the #destroyed flag but doesn't actually unsubscribe from the chatSubscription. If the component is destroyed while a chat request is in progress, the subscription will continue to run and attempt to update signals/state on a destroyed component, which is exactly the NG0953 error this PR aims to fix.

The manual destroyed checks in the subscription callbacks are a workaround, but a more robust solution would be to add the takeUntilDestroyed() operator to the subscription (line 303-406) and properly clean up the chatSubscription in the onDestroy callback.

Suggested change
this.#destroyed = true
this.#destroyed = true
this.chatSubscription?.unsubscribe()

Copilot uses AI. Check for mistakes.
if (this.chatSubscription && !this.chatSubscription.closed) {
this.chatSubscription.unsubscribe()
}
})
}

Expand Down Expand Up @@ -320,6 +326,12 @@ export class ChatConversationPreviewComponent {
.pipe(takeUntilDestroyed(this.#destroyRef))
.subscribe({
next: (msg) => {
// takeUntilDestroyed ensures subscription is cleaned up on component destruction,
// but we keep the check as an extra safety measure
if (this.#destroyed) {
return
}

if (msg.event === 'error') {
this.onChatError(msg.data)
} else {
Expand All @@ -339,6 +351,10 @@ export class ChatConversationPreviewComponent {
this.output.update((state) => state + event.data)
}
} else if (event.type === ChatMessageTypeEnum.EVENT) {
// Ensure component is not destroyed before emitting chat events
if (this.#destroyed) {
return
}
this.chatEvent.emit(event)
switch (event.event) {
case ChatMessageEventTypeEnum.ON_CONVERSATION_START:
Expand Down Expand Up @@ -377,9 +393,13 @@ export class ChatConversationPreviewComponent {
}
},
error: (err) => {
this.onChatError(getErrorMessage(err))
// takeUntilDestroyed ensures subscription is cleaned up, but keep check for safety
if (!this.#destroyed) {
this.onChatError(getErrorMessage(err))
}
},
complete: () => {
// takeUntilDestroyed ensures subscription is cleaned up, but keep check for safety
if (this.#destroyed) {
return
}
Expand All @@ -399,6 +419,7 @@ export class ChatConversationPreviewComponent {
}

onChatError(message: string) {
// #destroyed check is already done in error callback, no need to check here
this.loading.set(false)
if (this.currentMessage()) {
this.appendMessage({ ...this.currentMessage() })
Expand Down
22 changes: 12 additions & 10 deletions apps/cloud/src/app/features/xpert/studio/panel/panel.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,18 @@
}
}
@if (xpert()) {
<div class=""
[ngClass]="sidePanel() === 'preview' ? 'flex' : 'hidden'">
@if (executionId()) {
<xpert-studio-panel-execution [id]="executionId()" class="h-full bg-components-panel-bg shadow-lg border-[0.5px] border-components-panel-border rounded-2xl overflow-y-auto overflow-x-hidden
w-[420px] 2xl:w-[460px]"
(close)="closeExecution()"
/>
}
<xpert-studio-panel-preview (execution)="executionId.set($event)"/>
</div>
<!-- Preview component: keep mounted once created, use CSS display to control visibility -->
@if (showPreview()) {
<div [style.display]="sidePanel() === 'preview' ? 'flex' : 'none'">
@if (sidePanel() === 'preview' && executionId()) {
<xpert-studio-panel-execution [id]="executionId()" class="h-full bg-components-panel-bg shadow-lg border-[0.5px] border-components-panel-border rounded-2xl overflow-y-auto overflow-x-hidden
w-[420px] 2xl:w-[460px]"
(close)="closeExecution()"
/>
}
<xpert-studio-panel-preview (execution)="executionId.set($event)"/>
</div>
}
}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CdkMenuModule } from '@angular/cdk/menu'
import { CommonModule } from '@angular/common'
import { Component, computed, HostListener, inject, model, signal } from '@angular/core'
import { Component, computed, effect, HostListener, inject, model, signal } from '@angular/core'
import { listFadeIn } from 'apps/cloud/src/app/@core'
import { SelectionService } from '../domain'
import { XpertStudioComponent } from '../studio.component'
Expand Down Expand Up @@ -50,13 +50,28 @@ export class XpertStudioPanelComponent {
})
readonly xpert = this.studioComponent.xpert

// Track if preview component has been created, keep it mounted once created
readonly previewCreated = signal(false)
readonly showPreview = computed(() =>
this.sidePanel() === 'preview' || (this.previewCreated() && this.sidePanel() !== null)
)

readonly minPanelWidth = 420
readonly maxPanelWidth = 720
readonly panelWidth = signal(this.minPanelWidth)
private isResizing = false
private startX = 0
private startWidth = 0

constructor() {
// Mark preview component as created when switching to preview panel
effect(() => {
if (this.sidePanel() === 'preview') {
this.previewCreated.set(true)
}
}, { allowSignalWrites: true })
}

close() {
this.selectionService.selectNode(null)
}
Expand Down
Loading