fix(ui): guard resize observers against missing elements

This commit is contained in:
Federico Jaramillo Martinez
2026-05-20 22:22:03 +02:00
parent 56d4614aad
commit 8fbdd6e8f3
4 changed files with 42 additions and 15 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Prevent resize observers from attaching to missing UI elements during panel rerenders.
+16 -6
View File
@@ -48,8 +48,8 @@ export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView; @query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor; @query("prompt-editor") private promptEditor?: PromptEditor;
@query(".context-items") private contextItems?: HTMLElement; @query(".context-items") private contextItems?: HTMLElement | null;
@query(".mobile-tabs") private mobileTabs?: HTMLElement; @query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
@@ -696,7 +696,7 @@ export class PiWebApp extends LitElement {
} }
private observeContextItems(): void { private observeContextItems(): void {
const contextItems = this.contextItems; const contextItems = this.contextItemsElement();
if (this.observedContextItems === contextItems) return; if (this.observedContextItems === contextItems) return;
this.contextItemsResizeObserver?.disconnect(); this.contextItemsResizeObserver?.disconnect();
this.observedContextItems = contextItems; this.observedContextItems = contextItems;
@@ -709,7 +709,7 @@ export class PiWebApp extends LitElement {
} }
private updateContextScrollState(): void { private updateContextScrollState(): void {
const contextItems = this.contextItems; const contextItems = this.contextItemsElement();
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
@@ -717,8 +717,13 @@ export class PiWebApp extends LitElement {
if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight; if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight;
} }
private contextItemsElement(): HTMLElement | undefined {
const contextItems = this.contextItems;
return contextItems instanceof HTMLElement ? contextItems : undefined;
}
private observeMobileTabs(): void { private observeMobileTabs(): void {
const mobileTabs = this.mobileTabs; const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return; if (this.observedMobileTabs === mobileTabs) return;
this.mobileTabsResizeObserver?.disconnect(); this.mobileTabsResizeObserver?.disconnect();
this.observedMobileTabs = mobileTabs; this.observedMobileTabs = mobileTabs;
@@ -731,7 +736,7 @@ export class PiWebApp extends LitElement {
} }
private updateMobileTabsScrollState(): void { private updateMobileTabsScrollState(): void {
const mobileTabs = this.mobileTabs; const mobileTabs = this.mobileTabsElement();
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth); const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1; const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1; const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
@@ -739,6 +744,11 @@ export class PiWebApp extends LitElement {
if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight; if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight;
} }
private mobileTabsElement(): HTMLElement | undefined {
const mobileTabs = this.mobileTabs;
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
}
override render() { override render() {
const state = this.state; const state = this.state;
return html` return html`
+13 -6
View File
@@ -18,7 +18,7 @@ const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 };
export class TerminalPanel extends LitElement { export class TerminalPanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined; @property({ attribute: false }) workspace: Workspace | undefined;
@property({ type: Boolean }) autoStart = false; @property({ type: Boolean }) autoStart = false;
@query(".terminal-host") private terminalHost?: HTMLDivElement; @query(".terminal-host") private terminalHost?: HTMLDivElement | null;
@state() private terminals: TerminalInfo[] = []; @state() private terminals: TerminalInfo[] = [];
@state() private selectedId: string | undefined; @state() private selectedId: string | undefined;
@state() private loading = false; @state() private loading = false;
@@ -133,15 +133,16 @@ export class TerminalPanel extends LitElement {
private ensureTerminalView(): void { private ensureTerminalView(): void {
const workspace = this.workspace; const workspace = this.workspace;
if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || this.terminalHost === undefined || workspace === undefined) return; const terminalHost = this.terminalHostElement();
if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || terminalHost === undefined || workspace === undefined) return;
const terminal = new Terminal(terminalOptions(this)); const terminal = new Terminal(terminalOptions(this));
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
terminal.open(this.terminalHost); terminal.open(terminalHost);
this.terminal = terminal; this.terminal = terminal;
this.fitAddon = fitAddon; this.fitAddon = fitAddon;
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); }); this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
this.resizeObserver.observe(this.terminalHost); this.resizeObserver.observe(terminalHost);
terminal.onData((data) => { terminal.onData((data) => {
if (this.suppressTerminalInput) return; if (this.suppressTerminalInput) return;
const filtered = filterTerminalInput(data); const filtered = filterTerminalInput(data);
@@ -211,17 +212,23 @@ export class TerminalPanel extends LitElement {
private measureTerminalSize(): TerminalSize | undefined { private measureTerminalSize(): TerminalSize | undefined {
const currentSize = this.fitTerminal(); const currentSize = this.fitTerminal();
if (currentSize !== undefined) return currentSize; if (currentSize !== undefined) return currentSize;
if (this.terminal !== undefined || this.terminalHost === undefined) return undefined; const terminalHost = this.terminalHostElement();
if (this.terminal !== undefined || terminalHost === undefined) return undefined;
const measuringTerminal = new Terminal(terminalOptions(this)); const measuringTerminal = new Terminal(terminalOptions(this));
const measuringFitAddon = new FitAddon(); const measuringFitAddon = new FitAddon();
measuringTerminal.loadAddon(measuringFitAddon); measuringTerminal.loadAddon(measuringFitAddon);
measuringTerminal.open(this.terminalHost); measuringTerminal.open(terminalHost);
const size = terminalSizeFromDimensions(measuringFitAddon.proposeDimensions()); const size = terminalSizeFromDimensions(measuringFitAddon.proposeDimensions());
measuringTerminal.dispose(); measuringTerminal.dispose();
return size; return size;
} }
private terminalHostElement(): HTMLDivElement | undefined {
const terminalHost = this.terminalHost;
return terminalHost instanceof HTMLDivElement ? terminalHost : undefined;
}
private applyTerminalTheme(): void { private applyTerminalTheme(): void {
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this); if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
} }
+8 -3
View File
@@ -32,7 +32,7 @@ export class WorkspacePanel extends LitElement {
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined; @property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
@property({ type: Number }) activeTerminalCount = 0; @property({ type: Number }) activeTerminalCount = 0;
@property({ type: Boolean }) terminalAutoStart = false; @property({ type: Boolean }) terminalAutoStart = false;
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null;
@state() private workspaceHeaderCanScrollLeft = false; @state() private workspaceHeaderCanScrollLeft = false;
@state() private workspaceHeaderCanScrollRight = false; @state() private workspaceHeaderCanScrollRight = false;
@@ -99,7 +99,7 @@ export class WorkspacePanel extends LitElement {
} }
private observeWorkspaceHeaderStrip(): void { private observeWorkspaceHeaderStrip(): void {
const strip = this.workspaceHeaderStrip; const strip = this.workspaceHeaderStripElement();
if (this.observedWorkspaceHeaderStrip === strip) return; if (this.observedWorkspaceHeaderStrip === strip) return;
this.workspaceHeaderResizeObserver?.disconnect(); this.workspaceHeaderResizeObserver?.disconnect();
this.observedWorkspaceHeaderStrip = strip; this.observedWorkspaceHeaderStrip = strip;
@@ -112,7 +112,7 @@ export class WorkspacePanel extends LitElement {
} }
private updateWorkspaceHeaderScrollState(): void { private updateWorkspaceHeaderScrollState(): void {
const strip = this.workspaceHeaderStrip; const strip = this.workspaceHeaderStripElement();
const maxScrollLeft = strip === undefined ? 0 : Math.max(0, strip.scrollWidth - strip.clientWidth); const maxScrollLeft = strip === undefined ? 0 : Math.max(0, strip.scrollWidth - strip.clientWidth);
const canScrollLeft = strip !== undefined && strip.scrollLeft > 1; const canScrollLeft = strip !== undefined && strip.scrollLeft > 1;
const canScrollRight = strip !== undefined && maxScrollLeft - strip.scrollLeft > 1; const canScrollRight = strip !== undefined && maxScrollLeft - strip.scrollLeft > 1;
@@ -120,6 +120,11 @@ export class WorkspacePanel extends LitElement {
if (this.workspaceHeaderCanScrollRight !== canScrollRight) this.workspaceHeaderCanScrollRight = canScrollRight; if (this.workspaceHeaderCanScrollRight !== canScrollRight) this.workspaceHeaderCanScrollRight = canScrollRight;
} }
private workspaceHeaderStripElement(): HTMLElement | undefined {
const strip = this.workspaceHeaderStrip;
return strip instanceof HTMLElement ? strip : undefined;
}
private createPanelContext(workspace: Workspace): WorkspacePanelContext { private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return { return {
workspace, workspace,