fix(actions): avoid redundant action panel rerenders

This commit is contained in:
Federico Jaramillo Martinez
2026-05-28 20:27:12 +02:00
parent 61a763a4fb
commit 8f62deff1a
2 changed files with 57 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web-actions": patch
---
Prevent redundant workspace action panel re-renders from resetting mobile scroll position or replacing action buttons mid-click, and show feedback for stale, cancelled, or already-starting actions.
+52 -13
View File
@@ -15,6 +15,12 @@ type ConfigState =
| { kind: "loading" } | { kind: "loading" }
| WorkspaceActionsConfigLoadResult; | WorkspaceActionsConfigLoadResult;
interface ActionStatus {
kind: "info" | "success" | "error";
message: string;
detail?: string;
}
const configCache = new Map<string, ConfigState>(); const configCache = new Map<string, ConfigState>();
export function defineActionsPanelElement(): void { export function defineActionsPanelElement(): void {
@@ -33,7 +39,7 @@ class PiWebActionsPanel extends HTMLElement {
private openTerminalValue: OpenTerminal | undefined; private openTerminalValue: OpenTerminal | undefined;
private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined; private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined;
private runningActionId: string | undefined; private runningActionId: string | undefined;
private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined; private status: ActionStatus | undefined;
private readonly root: ShadowRoot; private readonly root: ShadowRoot;
private readonly onConfigChanged = () => { private readonly onConfigChanged = () => {
this.render(); this.render();
@@ -45,7 +51,14 @@ class PiWebActionsPanel extends HTMLElement {
} }
set workspace(value: Workspace | undefined) { set workspace(value: Workspace | undefined) {
const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue);
const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value);
this.workspaceValue = value; this.workspaceValue = value;
// Parent app updates should not rebuild this shadow DOM for the same workspace:
// doing so resets the mobile scroll position and can replace buttons mid-click.
if (previousKey === nextKey) return;
this.runningActionId = undefined;
this.status = undefined;
this.render(); this.render();
} }
@@ -83,6 +96,7 @@ class PiWebActionsPanel extends HTMLElement {
<button class="secondary" data-open-terminal>Open Terminal</button> <button class="secondary" data-open-terminal>Open Terminal</button>
</span> </span>
</section> </section>
${this.renderStatus()}
<section class="viewer actions-viewer"> <section class="viewer actions-viewer">
${this.renderConfigState(state)} ${this.renderConfigState(state)}
</section> </section>
@@ -94,8 +108,7 @@ class PiWebActionsPanel extends HTMLElement {
for (const button of this.root.querySelectorAll("button[data-action-id]")) { for (const button of this.root.querySelectorAll("button[data-action-id]")) {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const action = actionFromConfigState(state, button.getAttribute("data-action-id")); void this.dispatchActionById(workspace, button.getAttribute("data-action-id"));
if (action !== undefined) void this.dispatchAction(workspace, action);
}); });
} }
@@ -104,22 +117,36 @@ class PiWebActionsPanel extends HTMLElement {
}); });
} }
private dispatchActionById(workspace: Workspace, actionId: string | null): Promise<void> {
if (!this.isCurrentWorkspace(workspace)) return Promise.resolve();
const action = actionFromConfigState(getCachedWorkspaceConfig(workspace), actionId);
if (action === undefined) {
this.status = { kind: "error", message: "That action is no longer available. Click Refresh, then try again." };
this.render();
return Promise.resolve();
}
return this.dispatchAction(workspace, action);
}
private isCurrentWorkspace(workspace: Workspace): boolean {
return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace);
}
private renderConfigState(state: ConfigState): string { private renderConfigState(state: ConfigState): string {
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>${this.renderStatus()}`; if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>`;
if (state.kind === "missing") return `${renderMissingState(state)}${this.renderStatus()}`; if (state.kind === "missing") return renderMissingState(state);
if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`; if (state.kind === "unavailable") return renderUnavailableState(state);
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>${this.renderStatus()}`; if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>`;
return ` return `
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p> <p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${renderActionGroups(state.config.actions, this.runningActionId)} ${renderActionGroups(state.config.actions, this.runningActionId)}
${this.renderStatus()}
`; `;
} }
private renderStatus(): string { private renderStatus(): string {
if (this.status === undefined) return ""; if (this.status === undefined) return "";
const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`; const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`;
return `<div class="status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`; return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
} }
private async refreshConfig(workspace: Workspace): Promise<void> { private async refreshConfig(workspace: Workspace): Promise<void> {
@@ -128,6 +155,7 @@ class PiWebActionsPanel extends HTMLElement {
this.render(); this.render();
const state = await refreshWorkspaceConfig(workspace); const state = await refreshWorkspaceConfig(workspace);
if (!this.isCurrentWorkspace(workspace)) return;
this.status = state.kind === "loaded" this.status = state.kind === "loaded"
? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` } ? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` }
: undefined; : undefined;
@@ -135,8 +163,16 @@ class PiWebActionsPanel extends HTMLElement {
} }
private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise<void> { private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise<void> {
if (this.runningActionId !== undefined) return; if (this.runningActionId !== undefined) {
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return; this.status = { kind: "info", message: "Another action is already starting. Wait for it to finish dispatching, then try again." };
this.render();
return;
}
if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) {
this.status = { kind: "info", message: `Cancelled ${action.title}.` };
this.render();
return;
}
const terminal = this.terminalCommandRunsValue; const terminal = this.terminalCommandRunsValue;
if (terminal === undefined) { if (terminal === undefined) {
@@ -151,6 +187,7 @@ class PiWebActionsPanel extends HTMLElement {
try { try {
const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); const handle = await runWorkspaceActionInTerminal(terminal, workspace, action);
if (!this.isCurrentWorkspace(workspace)) return;
this.status = { this.status = {
kind: "success", kind: "success",
message: `Started terminal command “${handle.run.title}”.`, message: `Started terminal command “${handle.run.title}”.`,
@@ -159,6 +196,7 @@ class PiWebActionsPanel extends HTMLElement {
this.runningActionId = undefined; this.runningActionId = undefined;
this.render(); this.render();
} catch (error) { } catch (error) {
if (!this.isCurrentWorkspace(workspace)) return;
this.runningActionId = undefined; this.runningActionId = undefined;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render(); this.render();
@@ -260,8 +298,8 @@ function renderAction(action: WorkspaceAction, runningActionId: string | undefin
`; `;
} }
function actionFromConfigState(state: ConfigState, actionId: string | null): WorkspaceAction | undefined { function actionFromConfigState(state: ConfigState | undefined, actionId: string | null): WorkspaceAction | undefined {
if (state.kind !== "loaded" || actionId === null) return undefined; if (state?.kind !== "loaded" || actionId === null) return undefined;
return state.config.actions.find((action) => action.id === actionId); return state.config.actions.find((action) => action.id === actionId);
} }
@@ -287,6 +325,7 @@ function actionStyles(): string {
button:disabled { cursor: wait; opacity: 0.65; } button:disabled { cursor: wait; opacity: 0.65; }
.empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; } .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; }
.empty-state p { margin: 6px 0 0; } .empty-state p { margin: 6px 0 0; }
.panel-status { margin: 12px 12px 0; }
.status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; } .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; }
.status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); } .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); }
.status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); } .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); }